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
This commit is contained in:
michael 2025-08-15 16:18:55 +02:00
parent be3b9614d0
commit 3ed921312f
5 changed files with 625 additions and 86 deletions

229
src/config_parser.lua Normal file
View file

@ -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

View file

@ -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