diff --git a/.version_history b/.version_history index a9ec3f4..777e6ad 100644 --- a/.version_history +++ b/.version_history @@ -19,3 +19,4 @@ 25a29c32,c15b01a,fix/config-path-consistency,2025-09-05T15:21:25Z,michael,git,lua-api 795f8867,78e8ded,fix/json-library-compatibility,2025-09-05T15:44:42Z,michael,git,lua-api 795f8867,d4fa6e3,fix/ssl-dependency-check,2025-09-05T16:20:08Z,michael,git,lua-api +a670de0f,d271b84,refactor/extract-health-routes-and-server-core,2025-09-05T17:25:09Z,michael,git,lua-api diff --git a/src/http_server.lua b/src/http_server.lua new file mode 100644 index 0000000..08fbfd5 --- /dev/null +++ b/src/http_server.lua @@ -0,0 +1,257 @@ +-- src/http_server.lua +-- HTTP Server Core for Furt API-Gateway +-- Dragons@Work Digital Sovereignty Project + +local socket = require("socket") +local found_cjson, cjson = pcall(require, 'cjson') +if not found_cjson then + cjson = require('dkjson') +end + +local config = require("config.server") +local Auth = require("src.auth") + +-- HTTP-Server Module +local FurtServer = {} + +function FurtServer:new() + local instance = { + server = nil, + port = config.port or 7811, + host = config.host or "127.0.0.1", + routes = {} + } + setmetatable(instance, self) + self.__index = self + return instance +end + +-- Add route handler +function FurtServer:add_route(method, path, handler) + if not self.routes[method] then + self.routes[method] = {} + end + self.routes[method][path] = handler +end + +-- Add protected route (requires authentication) +function FurtServer:add_protected_route(method, path, required_permission, handler) + self:add_route(method, path, Auth.create_protected_route(required_permission, handler)) +end + +-- Parse HTTP request +function FurtServer:parse_request(client) + local request_line = client:receive() + if not request_line then + return nil + end + + -- Parse request line: "POST /v1/mail/send HTTP/1.1" + local method, path, protocol = request_line:match("(%w+) (%S+) (%S+)") + if not method then + return nil + end + + -- Parse headers + local headers = {} + local content_length = 0 + + while true do + local line = client:receive() + if not line or line == "" then + break + end + + local key, value = line:match("([^:]+): (.+)") + if key and value then + headers[key:lower()] = value + if key:lower() == "content-length" then + content_length = tonumber(value) or 0 + end + end + end + + -- Parse body + local body = "" + if content_length > 0 then + body = client:receive(content_length) + end + + return { + method = method, + path = path, + protocol = protocol, + headers = headers, + body = body, + content_length = content_length + } +end + +-- Add CORS headers with configurable origins +function FurtServer:add_cors_headers(request) + local allowed_origins = config.cors and config.cors.allowed_origins or { + "http://localhost:1313", + "http://127.0.0.1:1313", + "https://dragons-at-work.de", + "https://www.dragons-at-work.de" + } + + -- Check if request has Origin header + local origin = request and request.headers and request.headers.origin + local cors_origin = "*" -- Default fallback + + -- If origin is provided and in allowed list, use it + if origin then + for _, allowed in ipairs(allowed_origins) do + if origin == allowed then + cors_origin = origin + break + end + end + end + + return { + ["Access-Control-Allow-Origin"] = cors_origin, + ["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS", + ["Access-Control-Allow-Headers"] = "Content-Type, X-API-Key, Authorization, Accept", + ["Access-Control-Max-Age"] = "86400", + ["Access-Control-Allow-Credentials"] = "false" + } +end + +-- Create HTTP response +function FurtServer:create_response(status, data, content_type, additional_headers, request) + content_type = content_type or "application/json" + local body = "" + + if type(data) == "table" then + body = cjson.encode(data) + else + body = tostring(data or "") + end + + -- Start with CORS headers + local headers = self:add_cors_headers(request) + + -- Add standard headers + headers["Content-Type"] = content_type + headers["Content-Length"] = tostring(#body) + headers["Connection"] = "close" + headers["Server"] = "Furt-Lua/1.1" + + -- Add additional headers if provided + if additional_headers then + for key, value in pairs(additional_headers) do + headers[key] = value + end + end + + -- Build response + local response = string.format("HTTP/1.1 %d %s\r\n", status, self:get_status_text(status)) + + for key, value in pairs(headers) do + response = response .. key .. ": " .. value .. "\r\n" + end + + response = response .. "\r\n" .. body + + return response +end + +-- Get HTTP status text +function FurtServer:get_status_text(status) + local status_texts = { + [200] = "OK", + [204] = "No Content", + [400] = "Bad Request", + [401] = "Unauthorized", + [403] = "Forbidden", + [404] = "Not Found", + [405] = "Method Not Allowed", + [429] = "Too Many Requests", + [500] = "Internal Server Error" + } + return status_texts[status] or "Unknown" +end + +-- Handle client request +function FurtServer:handle_client(client) + local request = self:parse_request(client) + if not request then + local response = self:create_response(400, {error = "Invalid request"}, nil, nil, nil) + client:send(response) + return + end + + print(string.format("[%s] %s %s", os.date("%Y-%m-%d %H:%M:%S"), + request.method, request.path)) + + -- Handle OPTIONS preflight requests (CORS) + if request.method == "OPTIONS" then + local response = self:create_response(204, "", "text/plain", nil, request) + client:send(response) + return + end + + -- Route handling + local handler = nil + if self.routes[request.method] and self.routes[request.method][request.path] then + handler = self.routes[request.method][request.path] + end + + if handler then + local success, result = pcall(handler, request, self) + if success then + client:send(result) + else + print("Handler error: " .. tostring(result)) + local error_response = self:create_response(500, {error = "Internal server error"}, nil, nil, request) + client:send(error_response) + end + else + print("Route not found: " .. request.method .. " " .. request.path) + local response = self:create_response(404, {error = "Route not found", method = request.method, path = request.path}, nil, nil, request) + client:send(response) + end +end + +-- Start HTTP server +function FurtServer:start() + self.server = socket.bind(self.host, self.port) + if not self.server then + error("Failed to bind to " .. self.host .. ":" .. self.port) + end + + local HealthRoute = require("src.routes.health") + local version_info = HealthRoute.get_version_info() + + print(string.format("Furt HTTP-Server started on %s:%d", self.host, self.port)) + print("Version: " .. version_info.version .. " (merkwerk)") + 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") + + -- 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 + local client = self.server:accept() + if client then + client:settimeout(10) -- 10 second timeout + self:handle_client(client) + client:close() + end + end +end + +return FurtServer + diff --git a/src/main.lua b/src/main.lua index 5773714..0949efe 100644 --- a/src/main.lua +++ b/src/main.lua @@ -1,333 +1,31 @@ -- src/main.lua --- Pure Lua HTTP-Server for Furt API-Gateway +-- Furt API-Gateway - Application Entry Point -- Dragons@Work Digital Sovereignty Project -local socket = require("socket") -local found_cjson, cjson = pcall(require, 'cjson') -if not found_cjson then - cjson = require('dkjson') -end +-- Load HTTP Server Core +local FurtServer = require("src.http_server") --- Load modules -local Auth = require("src.auth") +-- Load Route Modules local MailRoute = require("src.routes.mail") local AuthRoute = require("src.routes.auth") +local HealthRoute = require("src.routes.health") -- Load configuration local config = require("config.server") -local function get_version_info() - -- Load merkwerk integration - local success, merkwerk = pcall(require, "integrations.lua-api") - if not success then - print("WARNING: merkwerk integration not available, using fallback") - return { - service = "furt-lua", - version = "?.?.?", - content_hash = "unknown", - vcs_info = { type = "none", hash = "", branch = "" }, - source = "fallback-no-merkwerk" - } - end - - -- Get merkwerk health info - local health_info = merkwerk.get_health_info() - - -- Ensure compatibility with old VERSION-only expectations - if not health_info.version then - health_info.version = "?.?.?" - end - - return health_info -end - --- HTTP-Server Module -local FurtServer = {} - -function FurtServer:new() - local instance = { - server = nil, - port = config.port or 8080, - host = config.host or "127.0.0.1", - routes = {} - } - setmetatable(instance, self) - self.__index = self - return instance -end - --- Add route handler -function FurtServer:add_route(method, path, handler) - if not self.routes[method] then - self.routes[method] = {} - end - self.routes[method][path] = handler -end - --- Add protected route (requires authentication) -function FurtServer:add_protected_route(method, path, required_permission, handler) - self:add_route(method, path, Auth.create_protected_route(required_permission, handler)) -end - --- Parse HTTP request -function FurtServer:parse_request(client) - local request_line = client:receive() - if not request_line then - return nil - end - - -- Parse request line: "POST /v1/mail/send HTTP/1.1" - local method, path, protocol = request_line:match("(%w+) (%S+) (%S+)") - if not method then - return nil - end - - -- Parse headers - local headers = {} - local content_length = 0 - - while true do - local line = client:receive() - if not line or line == "" then - break - end - - local key, value = line:match("([^:]+): (.+)") - if key and value then - headers[key:lower()] = value - if key:lower() == "content-length" then - content_length = tonumber(value) or 0 - end - end - end - - -- Parse body - local body = "" - if content_length > 0 then - body = client:receive(content_length) - end - - return { - method = method, - path = path, - protocol = protocol, - headers = headers, - body = body, - content_length = content_length - } -end - --- Add CORS headers with configurable origins -function FurtServer:add_cors_headers(request) - local allowed_origins = config.cors and config.cors.allowed_origins or { - "http://localhost:1313", - "http://127.0.0.1:1313", - "https://dragons-at-work.de", - "https://www.dragons-at-work.de" - } - - -- Check if request has Origin header - local origin = request and request.headers and request.headers.origin - local cors_origin = "*" -- Default fallback - - -- If origin is provided and in allowed list, use it - if origin then - for _, allowed in ipairs(allowed_origins) do - if origin == allowed then - cors_origin = origin - break - end - end - end - - return { - ["Access-Control-Allow-Origin"] = cors_origin, - ["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS", - ["Access-Control-Allow-Headers"] = "Content-Type, X-API-Key, Authorization, Accept", - ["Access-Control-Max-Age"] = "86400", - ["Access-Control-Allow-Credentials"] = "false" - } -end - --- Create HTTP response -function FurtServer:create_response(status, data, content_type, additional_headers, request) - content_type = content_type or "application/json" - local body = "" - - if type(data) == "table" then - body = cjson.encode(data) - else - body = tostring(data or "") - end - - -- Start with CORS headers - local headers = self:add_cors_headers(request) - - -- Add standard headers - headers["Content-Type"] = content_type - headers["Content-Length"] = tostring(#body) - headers["Connection"] = "close" - headers["Server"] = "Furt-Lua/1.1" - - -- Add additional headers if provided - if additional_headers then - for key, value in pairs(additional_headers) do - headers[key] = value - end - end - - -- Build response - local response = string.format("HTTP/1.1 %d %s\r\n", status, self:get_status_text(status)) - - for key, value in pairs(headers) do - response = response .. key .. ": " .. value .. "\r\n" - end - - response = response .. "\r\n" .. body - - return response -end - --- Get HTTP status text -function FurtServer:get_status_text(status) - local status_texts = { - [200] = "OK", - [204] = "No Content", - [400] = "Bad Request", - [401] = "Unauthorized", - [403] = "Forbidden", - [404] = "Not Found", - [405] = "Method Not Allowed", - [429] = "Too Many Requests", - [500] = "Internal Server Error" - } - return status_texts[status] or "Unknown" -end - --- Handle client request -function FurtServer:handle_client(client) - local request = self:parse_request(client) - if not request then - local response = self:create_response(400, {error = "Invalid request"}, nil, nil, nil) - client:send(response) - return - end - - print(string.format("[%s] %s %s", os.date("%Y-%m-%d %H:%M:%S"), - request.method, request.path)) - - -- Handle OPTIONS preflight requests (CORS) - if request.method == "OPTIONS" then - local response = self:create_response(204, "", "text/plain", nil, request) - client:send(response) - return - end - - -- Route handling - local handler = nil - if self.routes[request.method] and self.routes[request.method][request.path] then - handler = self.routes[request.method][request.path] - end - - if handler then - local success, result = pcall(handler, request, self) - if success then - client:send(result) - else - print("Handler error: " .. tostring(result)) - local error_response = self:create_response(500, {error = "Internal server error"}, nil, nil, request) - client:send(error_response) - end - else - print("Route not found: " .. request.method .. " " .. request.path) - local response = self:create_response(404, {error = "Route not found", method = request.method, path = request.path}, nil, nil, request) - client:send(response) - end -end - --- Start HTTP server -function FurtServer:start() - self.server = socket.bind(self.host, self.port) - if not self.server then - error("Failed to bind to " .. self.host .. ":" .. self.port) - end - - local version_info = get_version_info() - - print(string.format("Furt HTTP-Server started on %s:%d", self.host, self.port)) - print("Version: " .. version_info.version .. " (merkwerk)") - 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") - - -- 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 - local client = self.server:accept() - if client then - client:settimeout(10) -- 10 second timeout - self:handle_client(client) - client:close() - end - end -end - --- Initialize server and register routes +-- Initialize server local server = FurtServer:new() --- Public routes (no authentication required) -server:add_route("GET", "/health", function(request, server) - local version_info = get_version_info() - local response_data = { - status = "healthy", - service = version_info.service or "furt-lua", - version = version_info.version, - content_hash = version_info.content_hash, - vcs_info = version_info.vcs_info, - timestamp = os.time(), - source = version_info.source, - features = { - 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) +-- Register public routes (no authentication required) +server:add_route("GET", "/health", HealthRoute.handle_health) -- 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 (enabled via config)" - } - - -- Count headers - for _ in pairs(request.headers) do - response_data.headers_count = response_data.headers_count + 1 - end - - return server:create_response(200, response_data, nil, nil, request) - end) + server:add_route("POST", "/test", HealthRoute.handle_test) print("[WARN] Test endpoint enabled via configuration") end --- Protected routes (require authentication) +-- Register protected routes (require authentication) server:add_protected_route("POST", "/v1/mail/send", "mail:send", MailRoute.handle_mail_send) server:add_protected_route("GET", "/v1/auth/status", nil, AuthRoute.handle_auth_status) diff --git a/src/routes/health.lua b/src/routes/health.lua new file mode 100644 index 0000000..ac93338 --- /dev/null +++ b/src/routes/health.lua @@ -0,0 +1,80 @@ +-- src/routes/health.lua +-- Health monitoring and diagnostic routes for Furt API-Gateway +-- Dragons@Work Digital Sovereignty Project + +local found_cjson, cjson = pcall(require, 'cjson') +if not found_cjson then + cjson = require('dkjson') +end + +local config = require("config.server") + +local HealthRoute = {} + +-- Get version information from merkwerk integration +function HealthRoute.get_version_info() + -- Load merkwerk integration + local success, merkwerk = pcall(require, "integrations.lua-api") + if not success then + print("WARNING: merkwerk integration not available, using fallback") + return { + service = "furt-lua", + version = "?.?.?", + content_hash = "unknown", + vcs_info = { type = "none", hash = "", branch = "" }, + source = "fallback-no-merkwerk" + } + end + + -- Get merkwerk health info + local health_info = merkwerk.get_health_info() + + -- Ensure compatibility with old VERSION-only expectations + if not health_info.version then + health_info.version = "?.?.?" + end + + return health_info +end + +-- Handle /health endpoint - system health check +function HealthRoute.handle_health(request, server) + local version_info = HealthRoute.get_version_info() + local response_data = { + status = "healthy", + service = version_info.service or "furt-lua", + version = version_info.version, + content_hash = version_info.content_hash, + vcs_info = version_info.vcs_info, + timestamp = os.time(), + source = version_info.source, + features = { + 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 + +-- Handle /test endpoint - development testing (configurable) +function HealthRoute.handle_test(request, server) + local response_data = { + message = "Test endpoint working", + received_data = request.body, + headers_count = 0, + warning = "This is a development endpoint (enabled via config)" + } + + -- Count headers + for _ in pairs(request.headers) do + response_data.headers_count = response_data.headers_count + 1 + end + + return server:create_response(200, response_data, nil, nil, request) +end + +return HealthRoute +