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
+
+
+
+
+
+
+
+ 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.