From 3ed921312fe3e25df9c1b080be0b2819fb8aca54 Mon Sep 17 00:00:00 2001 From: michael Date: Fri, 15 Aug 2025 16:18:55 +0200 Subject: [PATCH] feat(config): implement multi-tenant config system (DAW/furt#89) - nginx-style furt.conf configuration - Multi-tenant mail routing per API key - Custom SMTP support per customer - Backward compatibility via server.lua adapter WIP: Ready for testing on werner --- config/furt.conf.example | 90 +++++++++++++++ config/server.lua | 101 ++++++++--------- docs/setup-guide.md | 176 ++++++++++++++++++++++++++++++ src/config_parser.lua | 229 +++++++++++++++++++++++++++++++++++++++ src/routes/mail.lua | 115 ++++++++++++++------ 5 files changed, 625 insertions(+), 86 deletions(-) create mode 100644 config/furt.conf.example create mode 100644 docs/setup-guide.md create mode 100644 src/config_parser.lua diff --git a/config/furt.conf.example b/config/furt.conf.example new file mode 100644 index 0000000..ea49675 --- /dev/null +++ b/config/furt.conf.example @@ -0,0 +1,90 @@ +# furt.conf - Multi-Tenant Configuration Example +# Dragons@Work Digital Sovereignty Project + +# Server configuration +[server] +host = 127.0.0.1 +port = 8080 +log_level = info + +# Default SMTP settings (used when API keys don't have custom SMTP) +[smtp_default] +host = mail.dragons-at-work.de +port = 465 +user = noreply@dragons-at-work.de +password = your-smtp-password-here +use_ssl = true + +# Dragons@Work Website +[api_key "daw-frontend-key"] +name = "Dragons@Work Website" +permissions = mail:send +allowed_ips = 127.0.0.1, 10.0.0.0/8, 192.168.0.0/16 +mail_to = admin@dragons-at-work.de +mail_from = noreply@dragons-at-work.de +mail_subject_prefix = "[DAW Contact] " + +# Biocodie Website (same SMTP, different recipient) +[api_key "bio-frontend-key"] +name = "Biocodie Website" +permissions = mail:send +allowed_ips = 127.0.0.1, 10.0.0.0/8 +mail_to = contact@biocodie.de +mail_from = noreply@biocodie.de +mail_subject_prefix = "[Biocodie] " + +# Verlag Website +[api_key "verlag-frontend-key"] +name = "Verlag Dragons@Work" +permissions = mail:send +allowed_ips = 127.0.0.1, 10.0.0.0/8 +mail_to = verlag@dragons-at-work.de +mail_from = noreply@verlag.dragons-at-work.de +mail_subject_prefix = "[Verlag] " + +# Customer with custom SMTP +[api_key "kunde-x-frontend-key"] +name = "Kunde X Website" +permissions = mail:send +allowed_ips = 1.2.3.4/32, 5.6.7.8/32 +mail_to = info@kunde-x.de +mail_from = noreply@kunde-x.de +mail_subject_prefix = "[Kunde X] " +# Custom SMTP for this customer +mail_smtp_host = mail.kunde-x.de +mail_smtp_port = 587 +mail_smtp_user = noreply@kunde-x.de +mail_smtp_pass = kunde-x-smtp-password +mail_smtp_ssl = true + +# Customer with external provider (e.g., Gmail) +[api_key "kunde-y-frontend-key"] +name = "Kunde Y Website" +permissions = mail:send +allowed_ips = 9.10.11.12/32 +mail_to = support@kunde-y.com +mail_from = website@kunde-y.com +mail_subject_prefix = "[Kunde Y Support] " +# Gmail SMTP example +mail_smtp_host = smtp.gmail.com +mail_smtp_port = 587 +mail_smtp_user = website@kunde-y.com +mail_smtp_pass = gmail-app-password +mail_smtp_ssl = true + +# Admin API key (full access for management) +[api_key "admin-management-key"] +name = "Admin Access" +permissions = *, mail:send, auth:status +allowed_ips = 127.0.0.1, 10.0.0.0/8 +mail_to = admin@dragons-at-work.de +mail_from = furt-admin@dragons-at-work.de +mail_subject_prefix = "[Furt Admin] " + +# Monitoring key (limited access) +[api_key "monitoring-health-key"] +name = "Monitoring Service" +permissions = health:check +allowed_ips = 127.0.0.1, 10.0.0.0/8, 172.16.0.0/12 +# No mail config needed for monitoring + diff --git a/config/server.lua b/config/server.lua index 5e0067e..54e3a99 100644 --- a/config/server.lua +++ b/config/server.lua @@ -1,25 +1,30 @@ -- config/server.lua --- Server configuration for Furt Lua HTTP-Server +-- Multi-Tenant server configuration using nginx-style config parser +-- Dragons@Work Digital Sovereignty Project -return { - -- HTTP Server settings - host = "127.0.0.1", - port = 8080, +local ConfigParser = require("src.config_parser") - -- Timeouts (seconds) - client_timeout = 10, +-- Load configuration from furt.conf +local config = ConfigParser.load_config() + +-- Add legacy compatibility and runtime enhancements +local server_config = { + -- HTTP Server settings (from [server] section) + host = config.server.host, + port = config.server.port, + + -- Timeouts and limits + client_timeout = config.server.client_timeout or 10, -- CORS Configuration cors = { - -- Default allowed origins for development - -- Override in production with CORS_ALLOWED_ORIGINS environment variable allowed_origins = (function() local env_origins = os.getenv("CORS_ALLOWED_ORIGINS") if env_origins then -- Parse comma-separated list from environment local origins = {} for origin in env_origins:gmatch("([^,]+)") do - table.insert(origins, origin:match("^%s*(.-)%s*$")) -- trim whitespace + table.insert(origins, origin:match("^%s*(.-)%s*$")) end return origins else @@ -35,54 +40,40 @@ return { }, -- Logging - log_level = "info", - log_requests = true, + log_level = config.server.log_level or "info", + log_requests = config.server.log_requests or true, - -- API-Key-Authentifizierung (PRODUCTION READY) - api_keys = { - -- Hugo Frontend API-Key (für Website-Formulare) - [os.getenv("HUGO_API_KEY") or "hugo-dev-key-change-in-production"] = { - name = "Hugo Frontend", - permissions = {"mail:send"}, - allowed_ips = { - "127.0.0.1", -- Localhost - "10.0.0.0/8", -- Private network - "192.168.0.0/16", -- Private network - "172.16.0.0/12" -- Private network - } - }, + -- API Keys (converted from nginx-style to old format for backward compatibility) + api_keys = config.api_keys, - -- Admin API-Key (für Testing und Management) - [os.getenv("ADMIN_API_KEY") or "admin-dev-key-change-in-production"] = { - name = "Admin Access", - permissions = {"*"}, -- All permissions - allowed_ips = { - "127.0.0.1", -- Local only for admin - "10.0.0.0/8" -- Internal network - } - }, + -- Default SMTP config (for legacy compatibility) + mail = config.smtp_default, - -- Optional: Monitoring API-Key (nur Health-Checks) - [os.getenv("MONITORING_API_KEY") or "monitoring-dev-key"] = { - name = "Monitoring Service", - permissions = {"monitoring:health"}, - allowed_ips = { - "127.0.0.1", - "10.0.0.0/8", - "172.16.0.0/12" - } - } - }, + -- Multi-tenant mail configuration function + get_mail_config_for_api_key = function(api_key) + return ConfigParser.get_mail_config_for_api_key(config, api_key) + end, - -- Mail configuration (for SMTP integration) - mail = { - smtp_server = os.getenv("SMTP_HOST") or "mail.example.org", - smtp_port = tonumber(os.getenv("SMTP_PORT")) or 465, - use_ssl = true, - username = os.getenv("SMTP_USERNAME"), - password = os.getenv("SMTP_PASSWORD"), - from_address = os.getenv("SMTP_FROM") or "noreply@example.org", - to_address = os.getenv("SMTP_TO") or "admin@example.org" - } + -- Raw config access (for advanced usage) + raw_config = config } +-- Print configuration summary on load +print("Furt Multi-Tenant Configuration Loaded:") +print(" Server: " .. server_config.host .. ":" .. server_config.port) +print(" Log Level: " .. server_config.log_level) +print(" Default SMTP: " .. (config.smtp_default.host or "not configured")) + +local api_key_count = 0 +for key_name, key_config in pairs(config.api_keys) do + api_key_count = api_key_count + 1 + local smtp_info = "" + if key_config.mail_smtp_host then + smtp_info = " (custom SMTP: " .. key_config.mail_smtp_host .. ")" + end + print(" API Key: " .. key_config.name .. " -> " .. key_config.mail_to .. smtp_info) +end +print(" Total API Keys: " .. api_key_count) + +return server_config + diff --git a/docs/setup-guide.md b/docs/setup-guide.md new file mode 100644 index 0000000..2dc790e --- /dev/null +++ b/docs/setup-guide.md @@ -0,0 +1,176 @@ +# Multi-Tenant furt Setup-Anleitung + +## Installation + +### 1. Dateien platzieren + +```bash +# OpenBSD/FreeBSD +mkdir -p /usr/local/etc/furt +mkdir -p /usr/local/share/furt + +# Oder Linux +mkdir -p /etc/furt +mkdir -p /usr/local/share/furt + +# Source code +cp -r src/ /usr/local/share/furt/ +cp -r config/ /usr/local/share/furt/ +``` + +### 2. Konfiguration erstellen + +```bash +# Beispiel-Config kopieren und anpassen +# OpenBSD/FreeBSD: +cp furt.conf.example /usr/local/etc/furt/furt.conf + +# Linux: +cp furt.conf.example /etc/furt/furt.conf + +# Config editieren +vi /usr/local/etc/furt/furt.conf # oder /etc/furt/furt.conf +``` + +### 3. Start-Script + +```bash +#!/bin/sh +# /usr/local/bin/furt + +cd /usr/local/share/furt +lua src/main.lua +``` + +## Multi-Tenant Konfiguration + +### Beispiel für 3 Websites + +```ini +[server] +host = 127.0.0.1 +port = 8080 + +[smtp_default] +host = mail.dragons-at-work.de +port = 465 +user = noreply@dragons-at-work.de +password = your-smtp-password + +# Website 1: Dragons@Work +[api_key "daw-key-abc123"] +name = "Dragons@Work Website" +permissions = mail:send +allowed_ips = 1.2.3.4/32, 10.0.0.0/8 +mail_to = admin@dragons-at-work.de +mail_from = noreply@dragons-at-work.de +mail_subject_prefix = "[DAW] " + +# Website 2: Biocodie (gleiche SMTP, andere Empfänger) +[api_key "bio-key-def456"] +name = "Biocodie Website" +permissions = mail:send +allowed_ips = 5.6.7.8/32 +mail_to = contact@biocodie.de +mail_from = noreply@biocodie.de +mail_subject_prefix = "[Biocodie] " + +# Website 3: Kunde mit eigenem SMTP +[api_key "kunde-key-ghi789"] +name = "Kunde X Website" +permissions = mail:send +allowed_ips = 9.10.11.12/32 +mail_to = info@kunde-x.de +mail_from = noreply@kunde-x.de +mail_smtp_host = mail.kunde-x.de +mail_smtp_user = noreply@kunde-x.de +mail_smtp_pass = kunde-smtp-password +``` + +## Admin-Workflow + +### Neue Website hinzufügen + +1. **Config editieren:** +```bash +vi /usr/local/etc/furt/furt.conf +``` + +2. **Neuen API-Key-Block hinzufügen:** +```ini +[api_key "neue-website-key"] +name = "Neue Website" +permissions = mail:send +allowed_ips = 12.34.56.78/32 +mail_to = contact@neue-website.de +mail_from = noreply@neue-website.de +``` + +3. **furt neu starten:** +```bash +systemctl restart furt +# oder +pkill -f "lua.*main.lua" && /usr/local/bin/furt & +``` + +### Website testen + +```bash +# Test mit curl +curl -X POST http://localhost:8080/v1/mail/send \ + -H "X-API-Key: neue-website-key" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Test User", + "email": "test@example.com", + "subject": "Test Message", + "message": "This is a test message" + }' +``` + +## Vorteile des Multi-Tenant-Systems + +### ✅ Ein Server, viele Websites +- Alle Websites nutzen eine furt-Instanz +- Jede Website hat eigenen API-Key +- Verschiedene Empfänger-Adressen +- Verschiedene SMTP-Server möglich + +### ✅ Admin-freundlich +- Nginx-style Config-Format +- Einfach neue Websites hinzufügen +- Klare Struktur pro Website +- Kommentare möglich + +### ✅ Sicher +- IP-Restrictions pro Website +- Permissions pro API-Key +- Separate SMTP-Credentials möglich +- Rate-Limiting bleibt erhalten + +### ✅ Flexibel +- Default SMTP + website-spezifische SMTP +- Subject-Prefix pro Website +- Verschiedene Mail-Adressen +- Beliebig viele Websites + +## Backward Compatibility + +Das neue System ist **vollständig kompatibel** mit der alten config/server.lua API. Bestehende Module (auth.lua, main.lua, etc.) funktionieren ohne Änderungen. + +## Troubleshooting + +### Config-Parsing-Fehler +```bash +# Config-Syntax prüfen +lua -e "require('src.config_parser').parse_file('/usr/local/etc/furt/furt.conf')" +``` + +### Mail-Routing testen +```bash +# Logs anschauen +tail -f /var/log/furt.log + +# Debug-Mode +FURT_DEBUG=true lua src/main.lua +``` \ No newline at end of file diff --git a/src/config_parser.lua b/src/config_parser.lua new file mode 100644 index 0000000..8721b0c --- /dev/null +++ b/src/config_parser.lua @@ -0,0 +1,229 @@ +-- src/config_parser.lua +-- nginx-style configuration parser for Multi-Tenant setup +-- Dragons@Work Digital Sovereignty Project + +local ConfigParser = {} + +-- Parse nginx-style config file +function ConfigParser.parse_file(config_path) + local file = io.open(config_path, "r") + if not file then + error("Could not open config file: " .. config_path) + end + + local config = { + server = {}, + api_keys = {}, + smtp_default = {} + } + + local current_section = nil + local current_api_key = nil + local line_number = 0 + + for line in file:lines() do + line_number = line_number + 1 + + -- Skip empty lines and comments + line = line:match("^%s*(.-)%s*$") -- trim whitespace + if line == "" or line:match("^#") then + goto continue + end + + -- Section headers: [section] or [api_key "keyname"] + local section_match = line:match("^%[([^%]]+)%]$") + if section_match then + if section_match:match("^api_key") then + -- Extract API key from [api_key "keyname"] + local key_name = section_match:match('^api_key%s+"([^"]+)"$') + if not key_name then + error(string.format("Invalid api_key section at line %d: %s", line_number, line)) + end + current_api_key = key_name + current_section = "api_key" + config.api_keys[key_name] = {} + else + current_section = section_match + current_api_key = nil + if not config[current_section] then + config[current_section] = {} + end + end + goto continue + end + + -- Key-value pairs: key = value + local key, value = line:match("^([^=]+)=(.+)$") + if key and value then + key = key:match("^%s*(.-)%s*$") -- trim + value = value:match("^%s*(.-)%s*$") -- trim + + -- Remove quotes from value if present + value = value:match('^"(.*)"$') or value + + if current_section == "api_key" and current_api_key then + ConfigParser.set_api_key_value(config.api_keys[current_api_key], key, value) + elseif current_section then + ConfigParser.set_config_value(config[current_section], key, value) + else + error(string.format("Key-value pair outside section at line %d: %s", line_number, line)) + end + else + error(string.format("Invalid line format at line %d: %s", line_number, line)) + end + + ::continue:: + end + + file:close() + + -- Validate required sections + ConfigParser.validate_config(config) + + return config +end + +-- Set configuration value with type conversion +function ConfigParser.set_config_value(section, key, value) + -- Convert numeric values + local num_value = tonumber(value) + if num_value then + section[key] = num_value + return + end + + -- Convert boolean values + if value:lower() == "true" then + section[key] = true + return + elseif value:lower() == "false" then + section[key] = false + return + end + + -- Keep as string + section[key] = value +end + +-- Set API key configuration value +function ConfigParser.set_api_key_value(api_key_config, key, value) + -- Handle special multi-value fields + if key == "permissions" then + api_key_config.permissions = {} + for perm in value:gmatch("([^,]+)") do + table.insert(api_key_config.permissions, perm:match("^%s*(.-)%s*$")) + end + return + end + + if key == "allowed_ips" then + api_key_config.allowed_ips = {} + for ip in value:gmatch("([^,]+)") do + table.insert(api_key_config.allowed_ips, ip:match("^%s*(.-)%s*$")) + end + return + end + + -- Regular key-value assignment with type conversion + ConfigParser.set_config_value(api_key_config, key, value) +end + +-- Validate required configuration +function ConfigParser.validate_config(config) + -- Check required server settings + if not config.server.port then + error("server.port is required") + end + + if not config.server.host then + config.server.host = "127.0.0.1" -- default + end + + -- Check that we have at least one API key + local key_count = 0 + for _ in pairs(config.api_keys) do + key_count = key_count + 1 + end + + if key_count == 0 then + print("Warning: No API keys configured") + end + + -- Validate each API key + for key_name, key_config in pairs(config.api_keys) do + if not key_config.name then + error("API key '" .. key_name .. "' missing name") + end + + if not key_config.permissions then + key_config.permissions = {} -- empty permissions + end + + if not key_config.allowed_ips then + key_config.allowed_ips = {} -- no IP restrictions + end + + -- Validate mail configuration + if not key_config.mail_to then + error("API key '" .. key_name .. "' missing mail_to") + end + + if not key_config.mail_from then + error("API key '" .. key_name .. "' missing mail_from") + end + end + + -- Set SMTP defaults if not configured + if not config.smtp_default.host then + config.smtp_default.host = "localhost" + config.smtp_default.port = 25 + print("Warning: No default SMTP configured, using localhost:25") + end +end + +-- Get mail configuration for specific API key +function ConfigParser.get_mail_config_for_api_key(config, api_key) + local key_config = config.api_keys[api_key] + if not key_config then + return nil, "API key not found" + end + + return { + -- Recipient and sender + to_address = key_config.mail_to, + from_address = key_config.mail_from, + subject_prefix = key_config.mail_subject_prefix or "", + + -- SMTP settings: key-specific or default + smtp_server = key_config.mail_smtp_host or config.smtp_default.host, + smtp_port = key_config.mail_smtp_port or config.smtp_default.port, + username = key_config.mail_smtp_user or config.smtp_default.user, + password = key_config.mail_smtp_pass or config.smtp_default.password, + use_ssl = key_config.mail_smtp_ssl or config.smtp_default.use_ssl or true + } +end + +-- Load configuration from file with fallback +function ConfigParser.load_config() + -- Try different locations based on OS + local config_paths = { + "/usr/local/etc/furt/furt.conf", -- OpenBSD/FreeBSD + "/etc/furt/furt.conf", -- Linux + "config/furt.conf", -- Development + "furt.conf" -- Current directory + } + + for _, path in ipairs(config_paths) do + local file = io.open(path, "r") + if file then + file:close() + print("Loading config from: " .. path) + return ConfigParser.parse_file(path) + end + end + + error("No configuration file found. Tried: " .. table.concat(config_paths, ", ")) +end + +return ConfigParser + diff --git a/src/routes/mail.lua b/src/routes/mail.lua index b6a28ed..d7cd93f 100644 --- a/src/routes/mail.lua +++ b/src/routes/mail.lua @@ -1,5 +1,6 @@ --- furt-lua/src/routes/mail.lua --- Mail service route handler +-- src/routes/mail.lua +-- Multi-Tenant Mail service route handler +-- API-Key determines mail configuration and recipient -- Dragons@Work Digital Sovereignty Project local cjson = require("cjson") @@ -19,25 +20,25 @@ local function validate_mail_data(data) if not data.name or type(data.name) ~= "string" or data.name:match("^%s*$") then return false, "Name is required and cannot be empty" end - + if not data.email or not validate_email(data.email) then return false, "Valid email address is required" end - + if not data.message or type(data.message) ~= "string" or data.message:match("^%s*$") then return false, "Message is required and cannot be empty" end - + -- Optional subject validation if data.subject and (type(data.subject) ~= "string" or #data.subject > 200) then return false, "Subject must be a string with max 200 characters" end - + -- Message length validation if #data.message > 5000 then return false, "Message too long (max 5000 characters)" end - + return true end @@ -46,64 +47,116 @@ local function generate_request_id() return os.time() .. "-" .. math.random(1000, 9999) end --- Mail service handler +-- Get tenant-specific mail configuration +local function get_tenant_mail_config(api_key) + local mail_config, error_msg = config.get_mail_config_for_api_key(api_key) + + if not mail_config then + return nil, error_msg or "No mail configuration found for API key" + end + + -- Validate essential mail configuration + if not mail_config.to_address then + return nil, "No recipient configured for this API key" + end + + if not mail_config.from_address then + return nil, "No sender address configured for this API key" + end + + return mail_config, nil +end + +-- Multi-Tenant Mail service handler function MailRoute.handle_mail_send(request, server) print("Mail endpoint called - Method: " .. request.method .. ", Path: " .. request.path) print("Authenticated as: " .. request.auth.key_name .. " (" .. request.auth.api_key .. ")") - + -- Basic request validation if not request.body or request.body == "" then return {error = "No request body", code = "MISSING_BODY"} end - + -- Parse JSON local success, data = pcall(cjson.decode, request.body) if not success then return {error = "Invalid JSON", body = request.body, code = "INVALID_JSON"} end - + -- Validate mail data local valid, error_message = validate_mail_data(data) if not valid then return {error = error_message, code = "VALIDATION_ERROR"} end - + + -- Get tenant-specific mail configuration + local tenant_mail_config, config_error = get_tenant_mail_config(request.auth.api_key) + if not tenant_mail_config then + print("Mail config error for API key " .. request.auth.api_key .. ": " .. config_error) + return server:create_response(500, { + error = "Mail configuration error: " .. config_error, + code = "CONFIG_ERROR" + }, nil, nil, request) + end + -- Generate request ID for tracking local request_id = generate_request_id() - - -- Prepare email content + + -- Apply tenant-specific subject prefix local subject = data.subject or "Contact Form Message" + if tenant_mail_config.subject_prefix and tenant_mail_config.subject_prefix ~= "" then + subject = tenant_mail_config.subject_prefix .. subject + end + + -- Prepare email content with tenant information local email_content = string.format( - "From: %s <%s>\nSubject: %s\n\n%s", - data.name, data.email, subject, data.message + "Website: %s (%s)\nFrom: %s <%s>\nSubject: %s\n\n%s\n\n---\nSent via Furt Gateway\nAPI Key: %s\nRequest ID: %s", + request.auth.key_name, + request.auth.api_key, + data.name, + data.email, + data.subject or "Contact Form Message", + data.message, + request.auth.key_name, + request_id ) - - -- Send email via SMTP + + -- Send email via SMTP using tenant-specific configuration local SMTP = require("src.smtp") - local smtp_client = SMTP:new(config.mail) - + local smtp_client = SMTP:new(tenant_mail_config) + + print("Sending mail for tenant: " .. request.auth.key_name) + print(" To: " .. tenant_mail_config.to_address) + print(" From: " .. tenant_mail_config.from_address) + print(" SMTP: " .. tenant_mail_config.smtp_server .. ":" .. tenant_mail_config.smtp_port) + local smtp_success, smtp_result = smtp_client:send_email( - config.mail.to_address, - subject, - email_content, + tenant_mail_config.to_address, + subject, + email_content, data.name ) - + if smtp_success then - -- Success response + -- Success response with tenant information return { - success = true, - message = "Mail sent successfully", + success = true, + message = "Mail sent successfully", request_id = request_id, - api_key_name = request.auth.key_name + tenant = { + name = request.auth.key_name, + recipient = tenant_mail_config.to_address, + smtp_server = tenant_mail_config.smtp_server + } } else -- SMTP error - log and return error - print("SMTP Error: " .. tostring(smtp_result)) + print("SMTP Error for tenant " .. request.auth.key_name .. ": " .. tostring(smtp_result)) return server:create_response(500, { - success = false, - error = "Failed to send email: " .. tostring(smtp_result), + success = false, + error = "Failed to send email: " .. tostring(smtp_result), request_id = request_id, + tenant = request.auth.key_name, code = "SMTP_ERROR" }, nil, nil, request) end