From 3ed921312fe3e25df9c1b080be0b2819fb8aca54 Mon Sep 17 00:00:00 2001 From: michael Date: Fri, 15 Aug 2025 16:18:55 +0200 Subject: [PATCH 1/5] 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 From 8ec401930c3978e18c2bf4ee7c7afadaafd3a372 Mon Sep 17 00:00:00 2001 From: michael Date: Thu, 28 Aug 2025 19:53:30 +0200 Subject: [PATCH 2/5] fix(config): lua 5.1 compatibility and multi-tenant validation - Replace goto statements with if-not pattern for Lua 5.1 compatibility - Validate mail config only for API keys with mail:send permissions - Safe display of API key info for monitoring keys without mail config - Fix health check SMTP detection for new config structure - Multi-tenant system tested and working on port 7811 Fixes multi-tenant config parsing, validation, and health checks. Related to DAW/furt#89 --- .gitignore | 1 + config/server.lua | 19 +++++++- src/config_parser.lua | 102 +++++++++++++++++++++++------------------- src/main.lua | 2 +- 4 files changed, 75 insertions(+), 49 deletions(-) diff --git a/.gitignore b/.gitignore index 0edc799..bddee23 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,4 @@ debug.log config.local.lua config.production.lua +config/furt.conf diff --git a/config/server.lua b/config/server.lua index 54e3a99..f2a05a7 100644 --- a/config/server.lua +++ b/config/server.lua @@ -67,11 +67,28 @@ 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 + + -- Check if this API key has mail permissions + local has_mail_permission = false + if key_config.permissions then + for _, perm in ipairs(key_config.permissions) do + if perm == "mail:send" or perm == "*" then + has_mail_permission = true + break + end + end + end + 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) + + if has_mail_permission then + print(" API Key: " .. key_config.name .. " -> " .. key_config.mail_to .. smtp_info) + else + print(" API Key: " .. key_config.name .. " (no mail)" .. smtp_info) + end end print(" Total API Keys: " .. api_key_count) diff --git a/src/config_parser.lua b/src/config_parser.lua index 8721b0c..6fa36d5 100644 --- a/src/config_parser.lua +++ b/src/config_parser.lua @@ -1,6 +1,7 @@ -- src/config_parser.lua -- nginx-style configuration parser for Multi-Tenant setup -- Dragons@Work Digital Sovereignty Project +-- Lua 5.1 compatible (no goto statements) local ConfigParser = {} @@ -26,53 +27,48 @@ function ConfigParser.parse_file(config_path) -- 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)) + if not (line == "" or line:match("^#")) then + -- 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 - 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] = {} + -- 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 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() @@ -163,13 +159,25 @@ function ConfigParser.validate_config(config) 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") + -- Validate mail configuration only if API key has mail:send permission + local has_mail_permission = false + if key_config.permissions then + for _, perm in ipairs(key_config.permissions) do + if perm == "mail:send" or perm == "*" then + has_mail_permission = true + break + end + end end - if not key_config.mail_from then - error("API key '" .. key_name .. "' missing mail_from") + if has_mail_permission then + 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 end diff --git a/src/main.lua b/src/main.lua index eee08ef..7e0268c 100644 --- a/src/main.lua +++ b/src/main.lua @@ -285,7 +285,7 @@ server:add_route("GET", "/health", function(request, server) timestamp = os.time(), source = version_info.source, features = { - smtp_configured = config.mail and config.mail.username ~= nil, + smtp_configured = config.smtp_default and config.smtp_default.host ~= nil, auth_enabled = true, rate_limiting = true, merkwerk_integrated = version_info.source == "merkwerk" From ecd4f68595b478a6d059db0049587aee0292c9ea Mon Sep 17 00:00:00 2001 From: michael Date: Thu, 28 Aug 2025 19:53:30 +0200 Subject: [PATCH 3/5] chore: merkwerk auto-update --- .version_history | 1 + 1 file changed, 1 insertion(+) diff --git a/.version_history b/.version_history index 7dea2cc..03113aa 100644 --- a/.version_history +++ b/.version_history @@ -4,3 +4,4 @@ 7e41647c,00b8a18,main,2025-08-19T19:36:33Z,michael,git,lua-api 7e41647c,62ddc17,main,2025-08-20T04:08:04Z,michael,git,lua-api 7e41647c,95dcdba,main,2025-08-28T15:34:36Z,michael,git,lua-api +7ca7e6d6,8ec4019,feature/issue-89-multi-tenant,2025-08-28T17:53:42Z,michael,git,lua-api From 5c17c86fd41821c8e2e69e74f2337cb8cecb0796 Mon Sep 17 00:00:00 2001 From: michael Date: Fri, 29 Aug 2025 20:01:47 +0200 Subject: [PATCH 4/5] feat(config): integrate rate limiting and CORS configuration from furt.conf - Add RateLimiter:configure() function to accept config-based limits - Integrate security section parameters (rate_limit_api_key_max, ip_max, window) - Add CORS configuration from config file with environment fallback - Replace hardcoded rate limiting defaults with configurable values - Add test endpoint control via config.security.enable_test_endpoint - Update startup logging to show actual configured rate limits - Add configuration validation and detailed startup information Rate limiting now uses values from [security] section instead of hardcoded defaults. CORS origins prioritize config file over environment variables. Related to DAW/furt#89 --- config/furt.conf.example | 14 +++++++- config/server.lua | 77 +++++++++++++++++++++++++++++----------- src/main.lua | 24 +++++++++---- src/rate_limiter.lua | 62 +++++++++++++++++++++----------- 4 files changed, 128 insertions(+), 49 deletions(-) diff --git a/config/furt.conf.example b/config/furt.conf.example index ea49675..28ffc8a 100644 --- a/config/furt.conf.example +++ b/config/furt.conf.example @@ -4,8 +4,20 @@ # Server configuration [server] host = 127.0.0.1 -port = 8080 +port = 7811 log_level = info +log_requests = true +client_timeout = 10 + +# CORS configuration +cors_allowed_origins = http://localhost:1313,http://127.0.0.1:1313,https://dragons-at-work.de,https://www.dragons-at-work.de + +# Security settings +[security] +rate_limit_api_key_max = 60 +rate_limit_ip_max = 100 +rate_limit_window = 3600 +enable_test_endpoint = false # Default SMTP settings (used when API keys don't have custom SMTP) [smtp_default] diff --git a/config/server.lua b/config/server.lua index f2a05a7..7c4ccfd 100644 --- a/config/server.lua +++ b/config/server.lua @@ -7,6 +7,45 @@ local ConfigParser = require("src.config_parser") -- Load configuration from furt.conf local config = ConfigParser.load_config() +-- Configure rate limiting from config +local RateLimiter = require("src.rate_limiter") +local rate_limits = { + api_key_max = config.security and config.security.rate_limit_api_key_max or 60, + ip_max = config.security and config.security.rate_limit_ip_max or 100, + window = config.security and config.security.rate_limit_window or 3600 +} +RateLimiter:configure(rate_limits) + +-- Parse CORS origins from config or environment +local function get_cors_origins() + -- 1. Try config file first + if config.server.cors_allowed_origins then + local origins = {} + for origin in config.server.cors_allowed_origins:gmatch("([^,]+)") do + table.insert(origins, origin:match("^%s*(.-)%s*$")) + end + return origins + end + + -- 2. Try environment variable + local env_origins = os.getenv("CORS_ALLOWED_ORIGINS") + if env_origins then + local origins = {} + for origin in env_origins:gmatch("([^,]+)") do + table.insert(origins, origin:match("^%s*(.-)%s*$")) + end + return origins + end + + -- 3. Development defaults + return { + "http://localhost:1313", -- Hugo dev server + "http://127.0.0.1:1313", -- Hugo dev server alternative + "http://localhost:3000", -- Common dev port + "http://127.0.0.1:3000" -- Common dev port alternative + } +end + -- Add legacy compatibility and runtime enhancements local server_config = { -- HTTP Server settings (from [server] section) @@ -16,33 +55,21 @@ local server_config = { -- Timeouts and limits client_timeout = config.server.client_timeout or 10, - -- CORS Configuration + -- CORS Configuration (prioritize config file over environment) cors = { - 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*$")) - end - return origins - else - -- Default development origins - return { - "http://localhost:1313", -- Hugo dev server - "http://127.0.0.1:1313", -- Hugo dev server alternative - "http://localhost:3000", -- Common dev port - "http://127.0.0.1:3000" -- Common dev port alternative - } - end - end)() + allowed_origins = get_cors_origins() }, -- Logging log_level = config.server.log_level or "info", log_requests = config.server.log_requests or true, + -- Security settings + security = { + enable_test_endpoint = config.security and config.security.enable_test_endpoint or false, + rate_limits = rate_limits + }, + -- API Keys (converted from nginx-style to old format for backward compatibility) api_keys = config.api_keys, @@ -62,8 +89,18 @@ local server_config = { print("Furt Multi-Tenant Configuration Loaded:") print(" Server: " .. server_config.host .. ":" .. server_config.port) print(" Log Level: " .. server_config.log_level) + +-- Print CORS configuration +print(" CORS Origins:") +for i, origin in ipairs(server_config.cors.allowed_origins) do + print(" " .. i .. ": " .. origin) +end + +-- Print security configuration +print(" Test Endpoint: " .. (server_config.security.enable_test_endpoint and "enabled" or "disabled")) print(" Default SMTP: " .. (config.smtp_default.host or "not configured")) +-- Print API key information local api_key_count = 0 for key_name, key_config in pairs(config.api_keys) do api_key_count = api_key_count + 1 diff --git a/src/main.lua b/src/main.lua index 7e0268c..8ee7281 100644 --- a/src/main.lua +++ b/src/main.lua @@ -1,4 +1,4 @@ --- furt-lua/src/main.lua +-- src/main.lua -- Pure Lua HTTP-Server for Furt API-Gateway -- Dragons@Work Digital Sovereignty Project @@ -256,8 +256,17 @@ function FurtServer:start() print("Content-Hash: " .. (version_info.content_hash or "unknown")) print("VCS: " .. (version_info.vcs_info and version_info.vcs_info.hash or "none")) print("API-Key authentication: ENABLED") - print("Rate limiting: ENABLED (60 req/hour per API key, 100 req/hour per IP)") - print("CORS enabled for configured origins") + + -- Show actual configured rate limits + local rate_limits = config.security and config.security.rate_limits + if rate_limits then + print(string.format("Rate limiting: ENABLED (%d req/hour per API key, %d req/hour per IP)", + rate_limits.api_key_max, rate_limits.ip_max)) + else + print("Rate limiting: ENABLED (default values)") + end + + print("CORS enabled for " .. (#config.cors.allowed_origins) .. " configured origins") print("Press Ctrl+C to stop") while true do @@ -288,20 +297,21 @@ server:add_route("GET", "/health", function(request, server) smtp_configured = config.smtp_default and config.smtp_default.host ~= nil, auth_enabled = true, rate_limiting = true, + rate_limits = config.security and config.security.rate_limits, merkwerk_integrated = version_info.source == "merkwerk" } } return server:create_response(200, response_data, nil, nil, request) end) --- Test endpoint for development (disable in production) -if os.getenv("ENABLE_TEST_ENDPOINT") == "true" then +-- Test endpoint for development (configurable via furt.conf) +if config.security and config.security.enable_test_endpoint then server:add_route("POST", "/test", function(request, server) local response_data = { message = "Test endpoint working", received_data = request.body, headers_count = 0, - warning = "This is a development endpoint" + warning = "This is a development endpoint (enabled via config)" } -- Count headers @@ -311,7 +321,7 @@ if os.getenv("ENABLE_TEST_ENDPOINT") == "true" then return server:create_response(200, response_data, nil, nil, request) end) - print("[WARN] Test endpoint enabled (development mode)") + print("[WARN] Test endpoint enabled via configuration") end -- Protected routes (require authentication) diff --git a/src/rate_limiter.lua b/src/rate_limiter.lua index 0d689c9..07b7a49 100644 --- a/src/rate_limiter.lua +++ b/src/rate_limiter.lua @@ -1,4 +1,4 @@ --- furt-lua/src/rate_limiter.lua +-- src/rate_limiter.lua -- Rate limiting system for API requests -- Dragons@Work Digital Sovereignty Project @@ -6,8 +6,8 @@ local RateLimiter = { requests = {}, -- {api_key = {timestamps}, ip = {timestamps}} cleanup_interval = 300, -- Cleanup every 5 minutes last_cleanup = os.time(), - - -- Default limits + + -- Default limits (configurable) default_limits = { api_key_max = 60, -- 60 requests per hour per API key ip_max = 100, -- 100 requests per hour per IP @@ -15,15 +15,35 @@ local RateLimiter = { } } +-- Configure rate limits from config +function RateLimiter:configure(limits) + if limits then + if limits.api_key_max then + self.default_limits.api_key_max = limits.api_key_max + end + if limits.ip_max then + self.default_limits.ip_max = limits.ip_max + end + if limits.window then + self.default_limits.window = limits.window + end + + print("Rate limiting configured:") + print(" API Key limit: " .. self.default_limits.api_key_max .. " req/hour") + print(" IP limit: " .. self.default_limits.ip_max .. " req/hour") + print(" Window: " .. self.default_limits.window .. " seconds") + end +end + -- Cleanup old requests from memory function RateLimiter:cleanup_old_requests() local now = os.time() if now - self.last_cleanup < self.cleanup_interval then return end - + local cutoff = now - self.default_limits.window - + for key, timestamps in pairs(self.requests) do local filtered = {} for _, timestamp in ipairs(timestamps) do @@ -33,21 +53,21 @@ function RateLimiter:cleanup_old_requests() end self.requests[key] = filtered end - + self.last_cleanup = now end -- Check if request is within rate limit function RateLimiter:check_rate_limit(key, max_requests, window_seconds) self:cleanup_old_requests() - + local now = os.time() local cutoff = now - (window_seconds or self.default_limits.window) - + if not self.requests[key] then self.requests[key] = {} end - + -- Count requests in time window local count = 0 for _, timestamp in ipairs(self.requests[key]) do @@ -55,15 +75,15 @@ function RateLimiter:check_rate_limit(key, max_requests, window_seconds) count = count + 1 end end - + -- Check if limit exceeded if count >= max_requests then return false, count, max_requests - count end - + -- Record this request table.insert(self.requests[key], now) - + return true, count + 1, max_requests - (count + 1) end @@ -71,11 +91,11 @@ end function RateLimiter:check_api_and_ip_limits(api_key, client_ip) -- Check API key rate limit local api_key_allowed, api_count, api_remaining = self:check_rate_limit( - "api_key:" .. api_key, - self.default_limits.api_key_max, + "api_key:" .. api_key, + self.default_limits.api_key_max, self.default_limits.window ) - + if not api_key_allowed then return false, "API key rate limit exceeded", { type = "api_key", @@ -84,14 +104,14 @@ function RateLimiter:check_api_and_ip_limits(api_key, client_ip) remaining = api_remaining } end - + -- Check IP rate limit local ip_allowed, ip_count, ip_remaining = self:check_rate_limit( - "ip:" .. client_ip, - self.default_limits.ip_max, + "ip:" .. client_ip, + self.default_limits.ip_max, self.default_limits.window ) - + if not ip_allowed then return false, "IP rate limit exceeded", { type = "ip", @@ -100,7 +120,7 @@ function RateLimiter:check_api_and_ip_limits(api_key, client_ip) remaining = ip_remaining } end - + -- Both limits OK return true, "OK", { api_key = { @@ -121,7 +141,7 @@ function RateLimiter:get_rate_limit_headers(limit_info) if not limit_info or not limit_info.api_key then return {} end - + return { ["X-RateLimit-Remaining"] = tostring(limit_info.api_key.remaining or 0), ["X-RateLimit-Limit"] = tostring(self.default_limits.api_key_max), From ef6b9950422047422fa245a5c26b1f5811b96f07 Mon Sep 17 00:00:00 2001 From: michael Date: Fri, 29 Aug 2025 20:01:47 +0200 Subject: [PATCH 5/5] chore: merkwerk auto-update --- .version_history | 1 + 1 file changed, 1 insertion(+) diff --git a/.version_history b/.version_history index 03113aa..50cff19 100644 --- a/.version_history +++ b/.version_history @@ -5,3 +5,4 @@ 7e41647c,62ddc17,main,2025-08-20T04:08:04Z,michael,git,lua-api 7e41647c,95dcdba,main,2025-08-28T15:34:36Z,michael,git,lua-api 7ca7e6d6,8ec4019,feature/issue-89-multi-tenant,2025-08-28T17:53:42Z,michael,git,lua-api +25a29c32,5c17c86,feature/issue-89-multi-tenant,2025-08-29T18:01:55Z,michael,git,lua-api