diff --git a/.gitignore b/.gitignore index d06e8a59..4085b356 100755 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,5 @@ ansible/test.json FastapiOpenRestyConfigurator/.env template_path backend_path -*/plans \ No newline at end of file +*/plans +.vscode/settings.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..869c2a5f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,65 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +### Development +- **Install Dependencies**: + ```bash + pip install -r FastapiOpenRestyConfigurator/requirements.txt + ``` +- **Run Application (Development)**: + ```bash + # From project root + export PYTHONPATH=$PYTHONPATH:$(pwd)/FastapiOpenRestyConfigurator + uvicorn FastapiOpenRestyConfigurator.main:app --reload + ``` +- **Run Application (Production)**: + ```bash + # Using gunicorn with provided config + gunicorn -c FastapiOpenRestyConfigurator/gunicorn_conf.py FastapiOpenRestyConfigurator.main:app + ``` + +### Testing +- **Run All Tests**: + ```bash + # From project root + export PYTHONPATH=$PYTHONPATH:$(pwd)/FastapiOpenRestyConfigurator + pytest FastapiOpenRestyConfigurator/tests + ``` +- **Run Single Test File**: + ```bash + export PYTHONPATH=$PYTHONPATH:$(pwd)/FastapiOpenRestyConfigurator + pytest FastapiOpenRestyConfigurator/tests/test_specific_file.py + ``` + +## Architecture + +The project (Flask OpenResty Configurator - FORC) is a FastAPI-based service that dynamically generates NGINX configuration snippets for an OpenResty web server. + +### High-Level Flow +1. **Request**: A REST API request is received by a `view`. +2. **Logic**: The `view` calls a `service` to perform business logic (e.g., creating a new backend). +3. **Templating**: The `service` uses Jinja2 templates to generate a configuration snippet. +4. **Persistence**: The snippet is written to the filesystem (`FORC_BACKEND_PATH`). +5. **Activation**: OpenResty is reloaded to apply the new configuration. + +### Project Structure (`FastapiOpenRestyConfigurator/`) +- `main.py`: Entry point; initializes the FastAPI app and includes routers. +- `app/main/views/`: API endpoints (Controllers). +- `app/main/service/`: Core business logic. + - `backend.py`: Manages backend configurations. + - `openresty.py`: Handles OpenResty interactions (e.g., reloading). + - `template.py`: Manages Jinja2 template rendering. + - `user.py`: User management logic. +- `app/main/model/`: Data models and Pydantic serializers. +- `app/main/util/`: Shared utilities for authentication, logging, and templating. +- `tests/`: Integration and unit tests. + +### Configuration +The application is configured via environment variables: +- `FORC_SECRET_KEY`: Encryption key for the service. +- `FORC_API_KEY`: API key for `X-API-KEY` authentication. +- `FORC_BACKEND_PATH`: Filesystem path where NGINX config snippets are stored. +- `FORC_TEMPLATE_PATH`: Filesystem path where Jinja2 templates are located. diff --git a/docker/Dockerfile b/docker/Dockerfile index e5fa0649..0c3b131f 100755 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -66,6 +66,7 @@ COPY docker/html /usr/local/openresty/nginx/html RUN mkdir -p ${FORC_BACKEND_PATH} ${FORC_TEMPLATE_PATH} /opt/scripts \ && chmod +x launch.sh /opt/scripts/generate_ip_blocklists.sh COPY examples/templates ${FORC_TEMPLATE_PATH} +COPY examples/scripts /var/forc/scripts/ EXPOSE 5000 CMD ["./launch.sh"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index db052c0b..f2ec6e4e 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,6 +1,6 @@ services: forc: - image: forc + image: oci.bi.denbi.de/simplevm/forc:dev container_name: forc restart: always env_file: @@ -10,6 +10,7 @@ services: #- /var/forc/template_path/:/var/forc/template_path/:rw # optional default has the exmaples/templates - /etc/letsencrypt/:/etc/letsencrypt/:r #needs to provided for cert - .env.forc:/opt/simpleVMWebGateway/FastapiOpenRestyConfigurator/.env # needs to be mounted for roc + ports: - 0.0.0.0:5000:5000 - 0.0.0.0:80:80 diff --git a/docker/nginx.conf b/docker/nginx.conf index 8764ff7a..d72fc7c1 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -11,7 +11,7 @@ events { http { include mime.types; - lua_package_path "{{ FORC_BACKEND_PATH }}/scripts/?.lua;;"; + lua_package_path "/var/forc/scripts/?.lua;;"; default_type application/octet-stream; @@ -355,6 +355,20 @@ init_by_lua_block { + location = /consent { + content_by_lua_block { + local consent_service = require("consent_service") + consent_service.render_consent_page() + } + } + + location = /consent/callback { + content_by_lua_block { + local consent_service = require("consent_service") + consent_service.handle_consent_post() + } + } + include {{ FORC_BACKEND_PATH }}/*.conf; @@ -379,4 +393,4 @@ init_by_lua_block { root html; } } -} \ No newline at end of file +} diff --git a/examples/scripts/consent_page.lua b/examples/scripts/consent_page.lua new file mode 100644 index 00000000..35e17dac --- /dev/null +++ b/examples/scripts/consent_page.lua @@ -0,0 +1,171 @@ +-- examples/scripts/consent_html.lua +local _M = {} + +function _M.render() + return[[ + + + + + + Consent Required + + + +
+
+ + + SimpleVM Web Services + +
+
+

+ The service you are about to access is provided by its users. + Neither SimpleVM nor de.NBI Cloud is responsible for the content + provided through this service. +

+ By continuing, you agree to the + + Terms of Service and Privacy Policy + . +

+ +
+ +
+ +
+
+ + + ]] +end + +return _M \ No newline at end of file diff --git a/examples/scripts/consent_service.lua b/examples/scripts/consent_service.lua new file mode 100644 index 00000000..b3e4242c --- /dev/null +++ b/examples/scripts/consent_service.lua @@ -0,0 +1,165 @@ +local session = require("resty.session") +local consent_page = require("consent_page") + +local _M = {} + +local CONSENT_TTL = 86400 + +-- Valid return_to URLs: must start with '/', no '//', no '\', no control characters, +-- and not be a system control endpoint. +local function is_valid_return_to(url) + ngx.log(ngx.DEBUG, "Validating return_to URL: ", url) + if not url or type(url) ~= "string" then return false end + if url == "/" then return true end + if not url:find("^/") then return false end + if url:sub(1, 2) == "//" then return false end + if url:find("\\", 1, true) then return false end + if url:find("[%z-\x1f\x7f]") then return false end + + local path_without_query = url:match("^([^?]*)") + local forbidden = { "/consent", "/consent/callback", "/redirect_uri" } + for _, path in ipairs(forbidden) do + if path_without_query == path then return false end + end + ngx.log(ngx.DEBUG, "Valid return_to URL: ", url) + return true +end + +local function is_valid_service(key_url, return_to) + ngx.log(ngx.DEBUG, "Validating service: key_url=", key_url, " return_to=", return_to) + if type(key_url) ~= "string" then return false end + if type(return_to) ~= "string" then return false end + local service_path = "/" .. key_url .. "/" + ngx.log(ngx.DEBUG, "Validating service: service_path=", service_path) + return return_to:sub(1, #service_path) == service_path +end + + +function _M.check_consent(key_url) + ngx.log(ngx.DEBUG, "Checking consent for key_url: ", key_url) + local sess, err, exists = session.open() + + ngx.log( + ngx.ERR, + "CONSENT CHECK: sess=", + tostring(sess), + " exists=", + tostring(exists), + " err=", + tostring(err) + ) + + if not sess then + ngx.log(ngx.ERR, "Failed to initialize session: ", err or "unknown") + return ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) + end + + local consent_key = "consent:" .. key_url + local consent_at = exists and sess:get(consent_key) or nil + + ngx.log( + ngx.ERR, + "CONSENT VALUES: given=", + tostring(consent_key), + " at=", + tostring(consent_at) + ) + + local consent_valid = + type(consent_at) == "number" + and ngx.time() - consent_at <= CONSENT_TTL + + if consent_valid then + return true + end + + local return_to = ngx.var.request_uri or "/" + local query = ngx.encode_args({ + return_to = return_to, + key_url = key_url + }) + return ngx.redirect("/consent?" .. query, 302) +end + + +function _M.render_consent_page() + ngx.log(ngx.DEBUG, "Rendering consent page") + if ngx.req.get_method() ~= "GET" then + return ngx.exit(ngx.HTTP_NOT_ALLOWED) + end + + -- Read and validate return_to before storing it. + local args = ngx.req.get_uri_args() + local return_to = args["return_to"] + local key_url = args["key_url"] + + ngx.log(ngx.DEBUG, "Validating: return_to=" .. return_to .. " | key_url=" .. key_url) + + if not is_valid_return_to(return_to) or not is_valid_service(key_url, return_to) then + return ngx.exit(ngx.HTTP_BAD_REQUEST) + end + + local sess, err = session.start() + + if not sess then + ngx.log(ngx.ERR, "Failed to start session: ", err or "unknown") + return ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) + end + + sess:set("showing_consent", true) + sess:set("consent_return_to", return_to) + sess:set("consent_key_url", key_url) + + local ok, save_err = sess:save() + if not ok then + ngx.log(ngx.ERR, "Failed to save session: ", save_err or "unknown") + return ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) + end + + ngx.header.content_type = "text/html; charset=utf-8" + ngx.say(consent_page.render()) +end + + +function _M.handle_consent_post() + ngx.log(ngx.DEBUG, "Handling consent POST") + local sess, err, exists = session.start() + + if not sess then + ngx.log(ngx.ERR, "Starting session failed: ", err or "unknown") + return ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) + end + + if not exists or sess:get("showing_consent") ~= true then + ngx.status = ngx.HTTP_FORBIDDEN + ngx.say("Forbidden: No consent flow active") + return ngx.exit(ngx.HTTP_FORBIDDEN) + end + + local return_to = sess:get("consent_return_to") + local key_url = sess:get("consent_key_url") + if not is_valid_return_to(return_to) then + return ngx.exit(ngx.HTTP_BAD_REQUEST) + end + if not is_valid_service(key_url, return_to) then + return ngx.exit(ngx.HTTP_BAD_REQUEST) + end + + local consent_key = "consent:" .. key_url + sess:set(consent_key, ngx.time()) + + sess:set("showing_consent", nil) + sess:set("consent_return_to", nil) + sess:set("consent_key_url", nil) + + local ok, save_err = sess:save() + + if not ok then + ngx.log(ngx.ERR, "Failed to save consent: ", save_err or "unknown") + return ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) + end + + return ngx.redirect(return_to, ngx.HTTP_SEE_OTHER) -- 303 See Other +end + +return _M diff --git a/examples/templates/webservice%v01.conf b/examples/templates/webservice%v01.conf index acdc2007..1ed26cc5 100755 --- a/examples/templates/webservice%v01.conf +++ b/examples/templates/webservice%v01.conf @@ -1,25 +1,16 @@ # PROTECT FIRST THEIA CONTAINER location /{{ key_url }}/ { - set $session_cipher none; # don't need to encrypt the session content, it's an opaque identifier - set $session_storage shm; # use shared memory - set $session_cookie_persistent on; # persist cookie between browser sessions - set $session_cookie_renew 3500; # new cookie every hour - set $session_cookie_lifetime 86400; # lifetime for persistent cookies - set $session_name sess_auth; # name of the cookie to store the session identifier in - - set $session_shm_store sessions; # name of the dict to store sessions in - # See https://github.com/bungle/lua-resty-session#shared-dictionary-storage-adapter for the following options - set $session_shm_uselocking off; - set $session_shm_lock_exptime 3; - set $session_shm_lock_timeout 2; - set $session_shm_lock_step 0.001; - set $session_shm_lock_ratio 1; - set $session_shm_lock_max_step 0.5; set $user_path '{{ forc_backend_path }}/users/{{backend_id}}/'; # Run this lua block, which checks if we are authenticated (again) und filters request by JWT (via id_token.sub) access_by_lua_block { local user_service = require("user_service") + + {% if not auth_enabled %} + local consent_service = require("consent_service") + consent_service.check_consent("{{ key_url }}") + {% endif %} + -- Start actual openid authentication procedure local res, err = require("resty.openidc").authenticate(opts2) -- If it fails for some reason, escape via HTTP 500 @@ -37,15 +28,6 @@ location /{{ key_url }}/ { {% else %} -- AUTH DISABLED, ALLOW ANY USER WITH A VALID TOKEN {% endif %} - - ngx.req.set_header("X-Auth-Audience", res.id_token.aud) - ngx.req.set_header("X-Auth-Email", res.id_token.email) - ngx.req.set_header("X-Auth-ExpiresIn", res.id_token.exp) - ngx.req.set_header("X-Auth-Name", res.id_token.name) - ngx.req.set_header("X-Auth-Subject", res.id_token.sub) - ngx.req.set_header("X-Auth-Userid", res.id_token.preferred_username) - ngx.req.set_header("X-Auth-Username", res.id_token.preferred_username) - ngx.req.set_header("X-Auth-Locale", res.id_token.locale) } # After check via lua-oidc is done, start reverse proxying this backend by configuring a billion headers.