diff --git a/README.md b/README.md index 176d153..9b77a28 100644 --- a/README.md +++ b/README.md @@ -1 +1,339 @@ -# pfsense-haproxy-authentik \ No newline at end of file +This documentation aims to outline the steps I did to get Authentik working via HAProxy on pfSense. + +## Before We Begin +I've tried my best to make this documentation as user and noob friendly as possible so even a complete beginner will be able to follow and get everything working. This documentation will not touch on every single thing but I did try to explain even the thing that I thought was common knowledge, so to all the seasoned persons reading, just bear with me... I want this to be noob friendly. + +### Some assumptions: +1. You will be using docker to host Authentik and you already have docker / docker compose installed in your environment. I will not touch on this as it is very easy to install and there are lots of tutorials on the internet about it, plus installing docker may vary depending on your OS and environment so for those reasons I will not touch on that. +2. I will be writing this documentation with the assumption that everyone following along has a "clean" environment, meaning they do not already have Authentik installed and do not have HAProxy installed and configured on pfSense. If you do not have a "clean" environment, you can still follow along but you will need to make necessary adjustments based on your environment. +3. It is assumed that you already have a pfSense instance, whether it be pfSense+ or pfSense CE, whether you have dedicated Netgate Hardware or running pfSense in a VM. Note: steps will be the same whether you are running pfSense+ or CE. + +### My Environment +The following network diagram is a snippet of my homelab. I will be using the information in the diagram to aid in this documentation. +![1.png](file:///C:%5CUsers%5Cl.buchanan%5CDropbox%5CObsidian%5CImages%5Cnotes%5Cpfsense%5Cauthentik-haproxy%5C1.png) + +## Step 1 - Download Required Files +This repo contains a copy of my docker compose stack I used to setup my Authentik instance. It also contain the lua files that are needed to be imported on pfSense to get HAProxy working. I also included links to to original repos in the table below where I got these files from in case you may want to get them from the source. + +| Name | Type | content | +| :------------------- | :------------ | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| json.lua | Lua script | copied from [https://github.com/rxi/json.lua](https://github.com/rxi/json.lua) | +| haproxy-lua-http.lua | Write to disk | copied from [https://github.com/haproxytech/haproxy-lua-http](https://github.com/haproxytech/haproxy-lua-http) | +| auth-request.lua | Lua script | copied from [https://github.com/TimWolla/haproxy-auth-request/blob/main/auth-request.lua](https://github.com/TimWolla/haproxy-auth-request/blob/main/auth-request.lua) | + +## Step 2 - Setup Authentik + +To get started logon to your docker host and download the official docker compose file using the following command +```bash +wget https://goauthentik.io/docker-compose.yml +``` + +You will need to create a `media`, `certs`, and `custom-templates` folder to hold your Authentik data. +```bash +mkdir media certs custom-templates +``` + +You will now need to generate a secret key for Authentik and a password for postgres and store them in a `.env` file. To do so run the following commands +```bash +echo "PG_PASS=$(openssl rand -base64 36 | tr -d '\n')" >> .env +echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 60 | tr -d '\n')" >> .env +``` + +Now you just need to run the containers. Do so by running the following command +```bash +docker compose up -d +``` + +After Authentik is running, browse to `https://[ip-of-docker-host]:[authentik-https-port]/if/flow/initial-setup/` (In my case it would be `https://192.168.50.10:9444/if/flow/initial-setup/`). You should be presented with a page to setup user email and password. + +### Configure Authentik Outpost, Provider and Application +Now that you setup Authentik and is logged in we will need to setup a Provider and an Application to handle Forward Auth requests. + +The first is to setup a new Provider for Forward Auth (Domain Level). In Authentik go to `Applications -> Providers` and create a new Provider. Select `Proxy Provider` and click Next to continue. +![2.png](file:///C:%5CUsers%5Cl.buchanan%5CDropbox%5CObsidian%5CImages%5Cnotes%5Cpfsense%5Cauthentik-haproxy%5C2.png) + +On the next screen give the provider a name and set Authentication Flow to ***...explicit-consent..***. Make sure `Forward auth (domain level)` is selected and enter the Authentication URL and Cookie domain. + +The `Authentication URL` is the proxy URL that you will use to access Authentik via HAProxy and the `Cookie domain` is your root domain. +![3.png](file:///C:%5CUsers%5Cl.buchanan%5CDropbox%5CObsidian%5CImages%5Cnotes%5Cpfsense%5Cauthentik-haproxy%5C3.png) + +Now scroll down to the `Advanced flow settings` section and make sure `Authentication flow` and `Invalidation flow` are set. I am using the defaults here. +![4.png](file:///C:%5CUsers%5Cl.buchanan%5CDropbox%5CObsidian%5CImages%5Cnotes%5Cpfsense%5Cauthentik-haproxy%5C4.png) + +Next is to setup a new Application. In Authentik go to `Applications -> Applications` and create a new Application. + +Give the application a name and slug and ***make sure to set the provider for the application to the one you just created*** +![5.png](file:///C:%5CUsers%5Cl.buchanan%5CDropbox%5CObsidian%5CImages%5Cnotes%5Cpfsense%5Cauthentik-haproxy%5C5.png) + +Next is to set the embedded outpost to use the Forward Auth Provider you created. In Authentik go to `Applications -> Outposts` and you should see the default embedded outpost +![6.png](file:///C:%5CUsers%5Cl.buchanan%5CDropbox%5CObsidian%5CImages%5Cnotes%5Cpfsense%5Cauthentik-haproxy%5C6.png) + +Edit the embedded outpost and make sure to copy the Forward Auth Application you created earlier over to the `Selected Applicaatios` box +![7.png](file:///C:%5CUsers%5Cl.buchanan%5CDropbox%5CObsidian%5CImages%5Cnotes%5Cpfsense%5Cauthentik-haproxy%5C7.png) + +Expand the `Advanced settings` section and for `authentik_host` you want to put the prosy URL that you will use to access Authentik over HAProxy. This should be the same URL you entered as the Authentication URL in the provider. +![8.png](file:///C:%5CUsers%5Cl.buchanan%5CDropbox%5CObsidian%5CImages%5Cnotes%5Cpfsense%5Cauthentik-haproxy%5C8.png) + +And that should be it for the setting up your Authentik instance. We will now move over to pfSense and getting HAProxy setup in the coming steps. + + +## Step 3 - Install and configure HAproxy + +On pfSense go to `System -> Package Manager -> Available Packages` and Search for haproxy. You will most likely see 2 haproxy packages, a `haproxy-devel` and the standard `haproxy` package. Install the standard haproxy package + +### Uploading lua files to pfSense +To Import the files on pfSense, go to `Diagnostics -> Command Prompt` and use the `Upload File` section to upload the 3 lua files. The files will be uploaded to the `/tmp/` directory on pfSense. + +Now you need to copy/move the files to the `/usr/local/share/lua/5.3` folder so HAProxy will be able to load those files. Again, on the `Diagnostics -> Command Prompt` screen, you need to use the `Execute Shell Command` section to copy the files from the `/tmp/` directory to the `/usr/local/share/lua/5.3` directory. Do so by executing the following shell commands: + +```bash +cp /tmp/json.lua /usr/local/share/lua/5.3/json.lua +``` + +```bash +cp /tmp/haproxy-lua-http.lua /usr/local/share/lua/5.3/haproxy-lua-http.lua +``` + +```bash +cp /tmp/auth-request.lua /usr/local/share/lua/5.3/auth-request.lua +``` + +### Configure HAProxy to use the lua files +Go to `Services -> HAProxy` and under the `Files` tab add the 3 lua files. When adding the files, you will need to copy all the contents of the files you are adding and paste it in the content section on pfSense. Use the image below as reference: +![9.jpg](file:///C:%5CUsers%5Cl.buchanan%5CDropbox%5CObsidian%5CImages%5Cnotes%5Cpfsense%5Cauthentik-haproxy%5C9.jpg) + +After adding the files, save and apply config, you should NOT get any errors. If you get any errors or warnings then something went wrong so recheck everything you did up to this point. + +## Step 4 - Configure Backends + +After successfully adding the required files from [[pfSense-HaProxy-Authentik Setup#Step 3 - Install and configure HAproxy|Step 3]] above, you can start to configure the backends. + +### Setup Backend for Authentik +You need to setup a backends for Authentik. To do so, go to `Services -> HAProxy -> Backend` and add a backend. + +For the backend I will simply name mine as `authentik-http`, fell free to name your backend whatever you want. This name will be relevant later on in the tutorial so remember it. You will also need to populate the Address and Port fields with the IP address of your Authentik instance and the port for http, then save. See image below for reference +![Screenshot 2025-05-01 101410.png](file:///C:%5CUsers%5Cl.buchanan%5CPictures%5CScreenshots%5CScreenshot%202025-05-01%20101410.png) + +### Setup backend you want to protect +In addition to setting up the backend for Authentik, you will also need to setup the backend/s for any service/s you want to protect. + +For the sake of this tutorial lets say I have a service (snipe-it) that I want to protect. This service is running at 192.168.12.3 on http port 4000. + +The backend setup is the same, give it a name and populate the Address and Port. +![Screenshot 2025-05-01 104631.png](file:///C:%5CUsers%5Cl.buchanan%5CPictures%5CScreenshots%5CScreenshot%202025-05-01%20104631.png) + +Now just save and apply config. You should NOT get any errors. If you get any errors or warnings then something went wrong so recheck everything you did up to this point. + + +## Step 5 - Configure Frontend + +### Create Frontend And Set Listen IP and Port +Now you need to setup a frontend to tie everything together. To do so, go to `Services -> HAProxy -> Frontend` and add a frontend. + +You can name the frontend whatever you want, the important thing is to set the Listen Address you want HAProxy to listen on and also the port you want it to listen on. See image below for reference: +![Screenshot 2025-05-01 110159.png](file:///C:%5CUsers%5Cl.buchanan%5CPictures%5CScreenshots%5CScreenshot%202025-05-01%20110159.png) + +### Setting up Access Control Lists +In the front end you will need to setup the ACL below ensuring it is the first ACL in the list. This ACL will determine which backends are protected by authentik based on the url. +``` +acl protected-frontends hdr(host) -m reg -i ^(?i)(service1|service2)\.yourdomain\.com +``` +Based on the ACL above the backends at `https://service1.yourdomain.com` and `https://service2.yourdomain.com` will be protected by a authentik login prompt. You can add more services by just adding a `|` and adding the other service you want to protect. For example if you want to protect a service at `https://service3.yourdomain.com` then the ACL would be modified to be `(service1|service2|service3)\.lojlocal\.com` + +See images below for reference: +![Screenshot 2025-05-01 112007.png](file:///C:%5CUsers%5Cl.buchanan%5CPictures%5CScreenshots%5CScreenshot%202025-05-01%20112007.png) + +Now setup the following ACLs as well. + +``` +acl protected-frontends var(txn.txnhost) -m reg -i ^(?i)(service1|service2)\.yourdomain\.com +``` +Reference: +![Screenshot 2025-05-01 112657.png](file:///C:%5CUsers%5Cl.buchanan%5CPictures%5CScreenshots%5CScreenshot%202025-05-01%20112657.png) + + +``` +acl is_authentikoutpost path -m reg ^/outpost.goauthentik.io/ +``` +Reference: +![Screenshot 2025-05-01 112708.png](file:///C:%5CUsers%5Cl.buchanan%5CPictures%5CScreenshots%5CScreenshot%202025-05-01%20112708.png) + + +``` +acl is_authentikoutpost var(txn.txnhost) -m reg ^/outpost.goauthentik.io/ +``` +Reference: +![Screenshot 2025-05-01 112715.png](file:///C:%5CUsers%5Cl.buchanan%5CPictures%5CScreenshots%5CScreenshot%202025-05-01%20112715.png) + + +For each backend you want to protect you will need to add an ACL for it, specifying the fqdn that backend service should be proxied through. Example below + +``` +acl service1 hdr(host) -i service1.yourdomain.com +``` +Reference: +![Screenshot 2025-05-01 112723.png](file:///C:%5CUsers%5Cl.buchanan%5CPictures%5CScreenshots%5CScreenshot%202025-05-01%20112723.png) + + +You will also need to create an ACL for authentik itself also and this one is important because whatever fqdn you use here should match the one set in Authentik. For mine, I have it set to `authentik.mydomain.com`. + +![Screenshot 2025-05-01 112805.png](file:///C:%5CUsers%5Cl.buchanan%5CPictures%5CScreenshots%5CScreenshot%202025-05-01%20112805.png) + + + +When you are done setting up the ACLs, you should have something similar to mine shown below: +![Screenshot 2025-05-01 114359.png](file:///C:%5CUsers%5Cl.buchanan%5CPictures%5CScreenshots%5CScreenshot%202025-05-01%20114359.png) + + +### Setting up Actions +The actions controls what happens when a incoming request to the proxy matches any ACLs. + +``` +http-request set-var(req.scheme) str(https) if { ssl_fc } +``` +If this is not set then there will be a warning in Authentik that HTTPS is not detected correctly. From my very short testing I did not see any noticeable impact to the functionality of Authentik when that warning was displayed, but I think it's worth pointing this out. + + +``` +http-request set-var(req.scheme) str(http) if !{ ssl_fc } +``` +Reference: +![[Screenshot 2025-05-01 115645.png]] + + + +``` +http-request set-var(req.questionmark) str(?) if { query -m found } +``` +Reference: +![[Screenshot 2025-05-01 115807.png]] + + + +``` +http-request set-header X-Real-IP %[src] +``` +Reference: +![[Screenshot 2025-05-01 120840.png]] + +![[Screenshot 2025-05-01 120906.png]] + + + +``` +http-request set-header X-Forwarded-Method %[method] +``` +Reference: +![[Screenshot 2025-05-01 121015.png]] + +![[Screenshot 2025-05-01 121030.png]] + + + +``` +http-request set-header X-Forwarded-Proto %[var(req.scheme)] +``` +Reference; +![[Screenshot 2025-05-01 121056.png]] + +![[Screenshot 2025-05-01 121109.png]] + + + +``` +http-request set-header X-Forwarded-Host %[req.hdr(Host)] +``` +Reference:![[Screenshot 2025-05-01 121204.png]] + +![[Screenshot 2025-05-01 121224.png]] + + + +``` +http-request set-header X-Original-URL %[url] +``` +Reference: +![[Screenshot 2025-05-01 121342.png]] + +![[Screenshot 2025-05-01 121358.png]] + + + +``` +http-request lua.auth-intercept authentik-http_ipvANY /outpost.goauthentik.io/auth/nginx HEAD x-original-url,x-real-ip,x-forwarded-host,x-forwarded-proto,user-agent,cookie,accept,x-forwarded-method x-authentik-username,x-authentik-uid,x-authentik-email,x-authentik-name,x-authentik-groups - if protected-frontends !is_authentikoutpost +``` +Reference: +![Screenshot 2025-05-01 121457_2.png](file:///C:%5CUsers%5Cl.buchanan%5CDownloads%5CScreenshot%202025-05-01%20121457_2.png) + +Make note of the `authentik-http_ipvANY`. This is the authentik http backed created earlier in the documentation, pfSense automatically adds `_ipvANY` to all backend names in the haproxy config. You should replace `authentik-http` with the name of your backend. + + +``` +http-request redirect code 302 location /outpost.goauthentik.io/start?rd=%[hdr(X-Original-URL)] if protected-frontends !{ var(txn.auth_response_successful) -m bool } { var(txn.auth_response_code) -m int 401 } !is_authentikoutpost +``` +Reference: +![[Screenshot 2025-05-01 122821.png]] + +![[Screenshot 2025-05-01 122839.png]] + + +``` +http-request deny if protected-frontends !{ var(txn.auth_response_successful) -m bool } { var(txn.auth_response_code) -m int 403 } !is_authentikoutpost +``` +Reference: +![[Screenshot 2025-05-01 122907.png]] + +![[Screenshot 2025-05-01 122956.png]] + + +``` +http-request redirect location %[var(txn.auth_response_location)] if protected-frontends !{ var(txn.auth_response_successful) -m bool } !is_authentikoutpost +``` +Reference: +![[Screenshot 2025-05-01 123019.png]] + +![[Screenshot 2025-05-01 123035.png]] + + + +``` +http-response set-header Strict-Transport-Security "max-age=63072000" +``` +Reference: +![Screenshot 2025-05-01 123126.png](file:///C:%5CUsers%5Cl.buchanan%5CPictures%5CScreenshots%5CScreenshot%202025-05-01%20123126.png) + + + +``` +http-request set-var(txn.txnhost) hdr(host) +``` +Reference: +![Screenshot 2025-05-01 123226.png](file:///C:%5CUsers%5Cl.buchanan%5CPictures%5CScreenshots%5CScreenshot%202025-05-01%20123226.png) + + + +``` +http-request set-var(txn.txnpath) path +``` +Reference: +![Screenshot 2025-05-01 123241.png](file:///C:%5CUsers%5Cl.buchanan%5CPictures%5CScreenshots%5CScreenshot%202025-05-01%20123241.png) + + + +``` +use_backend authentik-http_ipvANY if protected-frontends is_authentikoutpost +``` +Reference: +![Screenshot 2025-05-01 125120.png](file:///C:%5CUsers%5Cl.buchanan%5CPictures%5CScreenshots%5CScreenshot%202025-05-01%20125120.png) + + +Now you should add actions for all the backends. +![[Screenshot 2025-05-01 125508.png]] + +![[Screenshot 2025-05-01 125538.png]] +***NOTE:*** The names in the `See below` field should match the the names used when setting up the ACLs. So the names above should be the same as the name used when creating the ACLs below: +![Screenshot 2025-05-01 130019.png](file:///C:%5CUsers%5Cl.buchanan%5CPictures%5CScreenshots%5CScreenshot%202025-05-01%20130019.png) + +## Step 6 - Testing + +That should be it. Now you just need to test. Browse to the URL of any of your services that you are protecting with the `protected-frontends` ACL and it should redirect you to Authentik for authentication before you get access to the service. \ No newline at end of file diff --git a/auth-request.lua b/auth-request.lua new file mode 100644 index 0000000..1d14053 --- /dev/null +++ b/auth-request.lua @@ -0,0 +1,211 @@ +-- The MIT License (MIT) +-- +-- Copyright (c) 2018 Tim Düsterhus +-- +-- Permission is hereby granted, free of charge, to any person obtaining a copy +-- of this software and associated documentation files (the "Software"), to deal +-- in the Software without restriction, including without limitation the rights +-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +-- copies of the Software, and to permit persons to whom the Software is +-- furnished to do so, subject to the following conditions: +-- +-- The above copyright notice and this permission notice shall be included in all +-- copies or substantial portions of the Software. +-- +-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +-- SOFTWARE. +-- +-- SPDX-License-Identifier: MIT + +local http = require("haproxy-lua-http") + +core.register_action("auth-request", { "http-req" }, function(txn, be, path) + auth_request(txn, be, path, "HEAD", ".*", "-", "-") +end, 2) + +core.register_action("auth-intercept", { "http-req" }, function(txn, be, path, method, hdr_req, hdr_succeed, hdr_fail) + hdr_req = globToLuaPattern(hdr_req) + hdr_succeed = globToLuaPattern(hdr_succeed) + hdr_fail = globToLuaPattern(hdr_fail) + auth_request(txn, be, path, method, hdr_req, hdr_succeed, hdr_fail) +end, 6) + +function globToLuaPattern(glob) + if glob == "-" then + return "-" + end + -- magic chars: '^', '$', '(', ')', '%', '.', '[', ']', '*', '+', '-', '?' + -- https://www.lua.org/manual/5.4/manual.html#6.4.1 + -- + -- this chain is: + -- 1. escaping all the magic chars, adding a `%` in front of all of them, + -- except the chars being processed later in the chain; + -- 1.1. all the chars inside the [set] are magic chars and have special + -- meaning inside a set, so we're also escaping all of them to avoid + -- misbehavior; + -- 2. converting "match all" `*` and "match one" `?` to their Lua pattern + -- counterparts; + -- 3. adding start and finish boundaries outside the whole string and, + -- being a comma-separated list, between every single item as well. + return "^" .. glob:gsub("[%^%$%(%)%%%.%[%]%+%-]", "%%%1"):gsub("*", ".*"):gsub("?", "."):gsub(",", "$,^") .. "$" +end + +function set_var_pre_2_2(txn, var, value) + return txn:set_var(var, value) +end +function set_var_post_2_2(txn, var, value) + return txn:set_var(var, value, true) +end + +set_var = function(txn, var, value) + local success = pcall(set_var_post_2_2, txn, var, value) + if success then + set_var = set_var_post_2_2 + else + set_var = set_var_pre_2_2 + end + + return set_var(txn, var, value) +end + +function sanitize_header_for_variable(header) + return header:gsub("[^a-zA-Z0-9]", "_") +end + +-- header_match checks whether the provided header matches the pattern. +-- pattern is a comma-separated list of Lua Patterns. +function header_match(header, pattern) + if header == "content-length" or header == "host" or pattern == "-" then + return false + end + for p in pattern:gmatch("[^,]*") do + if header:match(p) then + return true + end + end + return false +end + +-- Terminates the transaction and sends the provided response to the client. +-- hdr_fail filters header names that should be provided using Lua Patterns. +function send_response(txn, response, hdr_fail) + local reply = txn:reply() + if response then + reply:set_status(response.status_code) + for header, value in response:get_headers(false) do + if header_match(header, hdr_fail) then + reply:add_header(header, value) + end + end + if response.content then + reply:set_body(response.content) + end + else + reply:set_status(500) + end + txn:done(reply) +end + +-- auth_request makes the request to the external authentication service +-- and waits for the response. hdr_* params receive a comma-separated +-- list of Lua Patterns used to identify the headers that should be +-- copied between the requests and responses. A dash `-` in these params +-- mean that the headers shouldn't be copied at all. +-- Special values and behavior: +-- * method == "*": call the auth service using the same method used by the client. +-- * hdr_fail == "-": make the Lua script to not terminate the request. +function auth_request(txn, be, path, method, hdr_req, hdr_succeed, hdr_fail) + set_var(txn, "txn.auth_response_successful", false) + + -- Check whether the given backend exists. + if core.backends[be] == nil then + txn:Alert("Unknown auth-request backend '" .. be .. "'") + set_var(txn, "txn.auth_response_code", 500) + return + end + + -- Check whether the given backend has servers that + -- are not `DOWN`. + local addr = nil + for name, server in pairs(core.backends[be].servers) do + local status = server:get_stats()['status'] + if status == "no check" or status:find("UP") == 1 then + addr = server:get_addr() + break + end + end + if addr == nil then + txn:Warning("No servers available for auth-request backend: '" .. be .. "'") + set_var(txn, "txn.auth_response_code", 500) + return + end + + -- Transform table of request headers from haproxy's to + -- socket.http's format. + local headers = {} + for header, values in pairs(txn.http:req_get_headers()) do + if header_match(header, hdr_req) then + for i, v in pairs(values) do + if headers[header] == nil then + headers[header] = v + else + headers[header] = headers[header] .. ", " .. v + end + end + end + end + + -- Make request to backend. + if method == "*" then + method = txn.sf:method() + end + local response, err = http.send(method:upper(), { + url = "http://" .. addr .. path, + headers = headers, + }) + + -- `terminate_on_failure == true` means that the Lua script should send the response + -- and terminate the transaction in the case of a failure. This will happen when + -- hdr_fail content isn't a dash `-`. + local terminate_on_failure = hdr_fail ~= "-" + + -- Check whether we received a valid HTTP response. + if response == nil then + txn:Warning("Failure in auth-request backend '" .. be .. "': " .. err) + set_var(txn, "txn.auth_response_code", 500) + if terminate_on_failure then + send_response(txn) + end + return + end + + set_var(txn, "txn.auth_response_code", response.status_code) + local response_ok = 200 <= response.status_code and response.status_code < 300 + + for header, value in response:get_headers(true) do + set_var(txn, "req.auth_response_header." .. sanitize_header_for_variable(header), value) + if response_ok and hdr_succeed ~= "-" and header_match(header, hdr_succeed) then + txn.http:req_set_header(header, value) + end + end + + -- response_ok means 2xx: allow request. + if response_ok then + set_var(txn, "txn.auth_response_successful", true) + -- Don't allow codes < 200 or >= 300. + -- Forward the response to the client if required. + elseif terminate_on_failure then + send_response(txn, response, hdr_fail) + -- Codes with Location: Passthrough location at redirect. + elseif response.status_code == 301 or response.status_code == 302 or response.status_code == 303 or response.status_code == 307 or response.status_code == 308 then + set_var(txn, "txn.auth_response_location", response:get_header("location", "last")) + -- 401 / 403: Do nothing, everything else: log. + elseif response.status_code ~= 401 and response.status_code ~= 403 then + txn:Warning("Invalid status code in auth-request backend '" .. be .. "': " .. response.status_code) + end +end \ No newline at end of file diff --git a/haproxy-lua-http.lua b/haproxy-lua-http.lua new file mode 100644 index 0000000..9917bab --- /dev/null +++ b/haproxy-lua-http.lua @@ -0,0 +1,830 @@ +-- +-- HTTP 1.1 library for HAProxy Lua modules +-- +-- The library is loosely modeled after Python's Requests Library +-- using the same field names and very similar calling conventions for +-- "HTTP verb" methods (where we use Lua specific named parameter support) +-- +-- In addition to client side, the library also supports server side request +-- parsing, where we utilize HAProxy Lua API for all heavy lifting. +-- +-- +-- Copyright (c) 2017-2020. Adis Nezirović +-- Copyright (c) 2017-2020. HAProxy Technologies, LLC. +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- +-- SPDX-License-Identifier: Apache-2.0 + +local _author = "Adis Nezirovic " +local _copyright = "Copyright 2017-2020. HAProxy Technologies, LLC." +local _version = "1.0.0" + +local json = require "json" + +-- Utility functions + +-- HTTP headers fetch helper +-- +-- Returns a header value(s) according to strategy (fold by default): +-- - single/string value for "fold", "first" and "last" strategies +-- - table for "all" strategy (for single value, a table with single element) +-- +-- @param hdrs table Headers table as received by http.get and friends +-- @param name string Header name +-- @param strategy string "multiple header values" handling strategy +-- @return header value (string or table) or nil +local function get_header(hdrs, name, strategy) + if hdrs == nil or name == nil then return nil end + + local v = hdrs[name:lower()] + if type(v) ~= "table" and strategy ~= "all" then return v end + + if strategy == nil or strategy == "fold" then + return table.concat(v, ",") + elseif strategy == "first" then + return v[1] + elseif strategy == "last" then + return v[#v] + elseif strategy == "all" then + if type(v) ~= "table" then + return {v} + else + return v + end + end +end + + + +-- HTTP headers iterator helper +-- +-- Returns key/value pairs for all header, making sure that returned values +-- are always of string type (if necessary, it folds multiple headers with +-- the same name) +-- +-- @param hdrs table Headers table as received by http.get and friends +-- @return header name/value iterator (suitable for use in "for" loops) +local function get_headers_folded(hdrs) + if hdrs == nil then + return function() end + end + + local function iter(t, k) + local v + k, v = next(t, k) + + if v ~= nil then + if type(v) ~= "table" then + return k, v + else + return k, table.concat(v, ",") + end + end + end + + return iter, hdrs, nil +end + +-- HTTP headers iterator +-- +-- Returns key/value pairs for all headers, for multiple headers with same name +-- it will return every name/value pair +-- (i.e. you can safely use it to process responses with 'Set-Cookie' header) +-- +-- @param hdrs table Headers table as received by http.get and friends +-- @return header name/value iterator (suitable for use in "for" loops) +local function get_headers_flattened(hdrs) + if hdrs == nil then + return function() end + end + + local k -- top level key (string) + local k_sub = 0 -- sub table key (integer), 0 if item not a table, + -- nil after last sub table iteration + local v_sub -- sub table + + return function () + local v + if k_sub == 0 then + k, v = next(hdrs, k) + if k == nil then return end + else + k_sub, v = next(v_sub, k_sub) + + if k_sub == nil then + k_sub = 0 + k, v = next(hdrs, k) + end + end + + if k == nil then return end + + if type(v) ~= "table" then + return k, v + else + v_sub = v + k_sub = k_sub + 1 + return k, v[k_sub] + end + end +end + + +--- Parse key/value pairs from a string +-- +-- @param s Lua string with (multiple) key/value pairs (separated by 'sep') +-- +-- @return Table with parsed keys and values or nil +local function parse_kv(s, sep) + if s == nil then return nil end + idx = 1 + result = {} + + while idx < s:len() do + i, j = s:find(sep, idx) + + if i == nil then + k, v = string.match(s:sub(idx), "^(.-)=(.*)$") + if k then result[k] = v end + break + end + + k, v = string.match(s:sub(idx, i-1), "^(.-)=(.*)$") + if k then result[k] = v end + idx = j + 1 + end + + if next(result) == nil then + return nil + else + return result + end +end + + +--- Make deep copy of table and it's values +-- +-- Use only for simple tables (it handles nested table values), but not for +-- Lua objects or similar, or very big tables (this uses recursion). +-- +-- @param t Cloned Lua table or nil +-- +-- @return Cloned table or nil +local function copyTable(t) + if type(t) ~= "table" then + return nil + end + + local r = {} + + for k, v in pairs(t) do + if type(v) == "table" then + r[k] = copyTable(v) + else + r[k] = v + end + end + + return r +end + + +--- Namespace object which hosts HTTP verb methods and request/response classes +local M = {} + + +--- HTTP response class +M.response = {} +M.response.__index = M.response + +local _reason = { + [200] = "OK", + [201] = "Created", + [204] = "No Content", + [301] = "Moved Permanently", + [302] = "Found", + [400] = "Bad Request", + [403] = "Forbidden", + [404] = "Not Found", + [405] = "Method Not Allowed", + [408] = "Request Timeout", + [413] = "Payload Too Large", + [429] = "Too many requests", + [500] = "Internal Server Error", + [501] = "Not Implemented", + [502] = "Bad Gateway", + [503] = "Service Unavailable", + [504] = "Gateway Timeout" +} + +--- Creates HTTP response from scratch +-- +-- @param status_code HTTP status code +-- @param reason HTTP status code text (e.g. "OK" for 200 response) +-- @param headers HTTP response headers +-- @param request The HTTP request which triggered the response +-- @param encoding Default encoding for response or conversions +-- +-- @return response object +function M.response.create(t) + local self = setmetatable({}, M.response) + + if not t then + t = {} + end + + self.status_code = t.status_code or nil + self.reason = t.reason or _reason[self.status_code] or "" + self.headers = copyTable(t.headers) or {} + self.content = t.content or "" + self.request = t.request or nil + self.encoding = t.encoding or "utf-8" + + return self +end + +function M.response.send(self, applet) + applet:set_status(tonumber(self.status_code), self.reason) + + for k, v in pairs(self.headers) do + if type(v) == "table" then + for _, hdr_val in pairs(v) do + applet:add_header(k, hdr_val) + end + else + applet:add_header(k, v) + end + end + + if not self.headers["content-type"] then + if type(self.content) == "table" then + applet:add_header("content-type", "application/json; charset=" .. + self.encoding) + if next(self.content) == nil then + -- Return empty JSON object for empty Lua tables + -- (that makes more sense then returning []) + self.content = "{}" + else + self.content = json.encode(self.content) + end + else + applet:add_header("content-type", "text/plain; charset=" .. + self.encoding) + end + end + + if not self.headers["content-length"] then + applet:add_header("content-length", #tostring(self.content)) + end + + applet:start_response() + applet:send(tostring(self.content)) +end + +--- Convert response content to JSON +-- +-- @return Lua table (decoded json) +function M.response.json(self) + return json.decode(self.content) +end + +-- Response headers getter +-- +-- Returns a header value(s) according to strategy (fold by default): +-- - single/string value for "fold", "first" and "last" strategies +-- - table for "all" strategy (for single value, a table with single element) +-- +-- @param name string Header name +-- @param strategy string "multiple header values" handling strategy +-- @return header value (string or table) or nil +function M.response.get_header(self, name, strategy) + return get_header(self.headers, name, strategy) +end + +-- Response headers iterator +-- +-- Yields key/value pairs for all headers, making sure that returned values +-- are always of string type +-- +-- @param folded boolean Specifies whether to fold headers with same name +-- @return header name/value iterator (suitable for use in "for" loops) +function M.response.get_headers(self, folded) + if folded == true then + return get_headers_folded(self.headers) + else + return get_headers_flattened(self.headers) + end +end + + +--- HTTP request class (client or server side, depending on the constructor) +M.request = {} +M.request.__index = M.request + +--- HTTP request constructor +-- +-- Parses client HTTP request (as forwarded by HAProxy) +-- +-- @param applet HAProxy AppletHTTP Lua object +-- +-- @return Request object +function M.request.parse(applet) + local self = setmetatable({}, M.request) + self.method = applet.method + + if (applet.method == "POST" or applet.method == "PUT") and + applet.length > 0 then + self.data = applet:receive() + if self.data == "" then self.data = nil end + end + + self.headers = {} + for k, v in pairs(applet.headers) do + if (v[1]) then -- (non folded header with multiple values) + self.headers[k] = {} + for _, val in pairs(v) do + table.insert(self.headers[k], val) + end + else + self.headers[k] = v[0] + end + end + + if not self.headers["host"] then + return nil, "Bad request, no Host header specified" + end + + self.cookies = parse_kv(self.headers["cookie"], "; ") + + -- TODO: Patch ApletHTTP and add schema of request + local schema = applet.schema or "http" + local url = {schema, "://", self.headers["host"], applet.path} + + self.params = {} + if applet.qs:len() > 0 then + for _, arg in ipairs(core.tokenize(applet.qs, "&", true)) do + kv = core.tokenize(arg, "=", true) + self.params[kv[1]] = kv[2] + end + url[#url+1] = "?" + url[#url+1] = applet.qs + end + + self.url = table.concat(url) + + return self +end + +--- Escape Lua pattern chars in HTTP multipart boundary +-- +-- This function escapes only minimal number of characters, which can be +-- observed in multipart boundaries, namely: -, +, ? and . +-- +-- @param s string Data to escape +-- +-- @return escaped data (string) +local function escape_pattern(s) + return s:gsub("%-", "%%-"):gsub("%+", "%%+"):gsub("%?", "%%?"):gsub("%.", "%%.") +end + +--- Parse HTTP POST data +-- +-- @return Table with submitted form data +function M.request.parse_multipart(self) + local ct = self.headers['content-type'] + if ct == nil then + return nil, 'Content-Type header not present' + end + + if self.data == nil then + return nil, 'Empty body' + end + local body = self.data + local result ={} + + if ct:find('^multipart/form[-]data;') then + local boundary = ct:match('^multipart/form[-]data; boundary=["]?(.+)["]?$') + if boundary == nil then + return nil, 'Could not parse boundary from Content-Type' + end + + -- per RFC2046, CLRF is treated as a part of boundary + -- but first one does not have it, so we're going pretend + -- it is part of the content and ignore it there (in the pattern) + boundary = string.format('%%-%%-%s.-\r\n', escape_pattern(boundary)) + + local i = 1 + local j + local old_i + + while true do + i, j = body:find(boundary, i) + + if i == nil then break end + + if old_i then + local part = body:sub(old_i, i - 1) + local k, fn, t, v = part:match('^[cC]ontent[-][dD]isposition: form[-]data; name[=]"(.+)"; filename="(.+)"\r\n[cC]ontent[-][tT]ype: (.+)\r\n\r\n(.+)\r\n$') + + if k then + result[k] = { + filename = fn, + content_type = t, + data = v + } + else + k, v = part:match('^[cC]ontent[-][dD]isposition: form[-]data; name[=]"(.+)"\r\n\r\n(.+)\r\n$') + + if k then + result[k] = v + end + end + + end + + i = j + 1 + old_i = i + end + elseif ct == 'application/x-www-form-urlencoded' then + result = parse_kv(body, '&') + else + return nil, 'Unsupported Content-Type: ' .. ct + end + + if result == nil or not next(result) then + return nil, 'Could not parse form data' + end + + return result +end + +--- Reads (all) chunks from a HTTP response +-- +-- @param socket socket object (with already established tcp connection) +-- @param get_all boolean (true by default), collect all chunks at once +-- or yield every chunk separately. +-- +-- @return Full response payload or nil and an error message +function M.receive_chunked(socket, get_all) + if socket == nil then + return nil, "http.receive_chunked: Socket is nil" + end + local data = {} + + while true do + local chunk, err = socket:receive("*l") + + if chunk == nil then + return nil, "http.receive_chunked(): Receive error (chunk length): " .. tostring(err) + end + + local chunk_len = tonumber(chunk, 16) + if chunk_len == nil then + return nil, "http.receive_chunked(): Could not parse chunk length" + end + + if chunk_len == 0 then + -- TODO: support trailers + break + end + + -- Consume next chunk (including the \r\n) + chunk, err = socket:receive(chunk_len+2) + if chunk == nil then + return nil, "http.receive_chunked(): Receive error (chunk data): " .. tostring(err) + end + + -- Strip the \r\n before collection + local chunk_data = string.sub(chunk, 1, -3) + + if get_all == false then + return chunk_data + end + + table.insert(data, chunk_data) + end + + return table.concat(data) +end + + +-- Request headers getter +-- +-- Returns a header value(s) according to strategy (fold by default): +-- - single/string value for "fold", "first" and "last" strategies +-- - table for "all" strategy (for single value, a table with single element) +-- +-- @param name string Header name +-- @param strategy string "multiple header values" handling strategy +-- @return header value (string or table) or nil +function M.request.get_header(self, name, strategy) + return get_header(self.headers, name, strategy) +end + +-- Request headers iterator +-- +-- Yields key/value pairs for all headers, making sure that returned values +-- are always of string type +-- +-- @param hdrs table Headers table as received by http.get and friends +-- @param folded boolean Specifies whether to fold headers with same name +-- @return header name/value iterator (suitable for use in "for" loops) +function M.request.get_headers(self, folded) + if folded == true then + return get_headers_folded(self.headers) + else + return get_headers_flattened(self.headers) + end +end + +--- Creates HTTP request from scratch +-- +-- @param method HTTP method +-- @param url Valid HTTP url +-- @param headers Lua table with request headers +-- @param data Request content +-- @param params Lua table with request url arguments +-- @param auth (username, password) tuple for HTTP auth +-- +-- @return request object +function M.request.create(t) + local self = setmetatable({}, M.request) + + if t.method then + self.method = t.method:lower() + else + self.method = "get" + end + self.url = t.url or nil + self.headers = copyTable(t.headers) or {} + self.data = t.data or nil + self.params = copyTable(t.params) or {} + self.auth = copyTable(t.auth) or {} + + return self +end + +--- HTTP HEAD request +function M.head(t) + return M.send("HEAD", t) +end + +--- HTTP GET request +function M.get(t) + return M.send("GET", t) +end + +--- HTTP PUT request +function M.put(t) + return M.send("PUT", t) +end + +--- HTTP POST request +function M.post(t) + return M.send("POST", t) +end + +--- HTTP DELETE request +function M.delete(t) + return M.send("DELETE", t) +end + + +--- Send HTTP request +-- +-- @param method HTTP method +-- @param url Valid HTTP url (mandatory) +-- @param headers Lua table with request headers +-- @param data Request content +-- @param params Lua table with request url arguments +-- @param auth (username, password) tuple for HTTP auth +-- @param timeout Optional timeout for socket operations (5s by default) +-- +-- @return Response object or tuple (nil, msg) on errors + +-- Note that the prefered way to call this method is via Lua +-- "keyword arguments" convention, e.g. +-- http.get{uri="http://example.net"} +function M.send(method, t) + if type(t) ~= "table" then + return nil, "http." .. method:lower() .. + ": expecting Request object for named parameters" + end + + if type(t.url) ~= "string" then + return nil, "http." .. method:lower() .. ": 'url' parameter missing" + end + + local socket = core.tcp() + socket:settimeout(t.timeout or 5) + local connect + if t.url:sub(1, 7) ~= "http://" and t.url:sub(1, 8) ~= "https://" then + t.url = "http://" .. t.url + end + local schema, host, req_uri = t.url:match("^(.*)://(.-)(/.*)$") + + if not schema then + -- maybe path (request uri) is missing + schema, host = t.url:match("^(.*)://(.-)$") + if not schema then + return nil, "http." .. method:lower() .. ": Could not parse URL: " .. t.url + end + req_uri = "/" + end + + local addr, port = host:match("(.*):(%d+)") + + if schema == "http" then + connect = socket.connect + if not port then + addr = host + port = 80 + end + elseif schema == "https" then + connect = socket.connect_ssl + if not port then + addr = host + port = 443 + end + else + return nil, "http." .. method:lower() .. ": Invalid URL schema " .. tostring(schema) + end + + local c, err = connect(socket, addr, port) + + if c then + local req = {} + local hdr_tbl = {} + + if t.headers then + for k, v in pairs(t.headers) do + if type(v) == "table" then + table.insert(hdr_tbl, k .. ": " .. table.concat(v, ",")) + else + table.insert(hdr_tbl, k .. ": " .. tostring(v)) + end + end + else + t.headers = {} -- dummy table + end + + if not t.headers.host then + -- 'Host' header must be provided for HTTP/1.1 + table.insert(hdr_tbl, "host: " .. host) + end + + if not t.headers["accept"] then + table.insert(hdr_tbl, "accept: */*") + end + + if not t.headers["user-agent"] then + table.insert(hdr_tbl, "user-agent: haproxy-lua-http/1.0") + end + + if not t.headers.connection then + table.insert(hdr_tbl, "connection: close") + end + + if t.data then + req[4] = t.data + if not t.headers or not t.headers["content-length"] then + table.insert(hdr_tbl, "content-length: " .. tostring(#t.data)) + end + end + + req[1] = method .. " " .. req_uri .. " HTTP/1.1\r\n" + req[2] = table.concat(hdr_tbl, "\r\n") + req[3] = "\r\n\r\n" + + local r, e = socket:send(table.concat(req)) + + if not r then + socket:close() + return nil, "http." .. method:lower() .. ": " .. tostring(e) + end + + local line + r = M.response.create() + + while true do + line, err = socket:receive("*l") + + if not line then + socket:close() + return nil, "http." .. method:lower() .. + ": Receive error (headers): " .. err + end + + if line == "" then break end + + if not r.status_code then + _, r.status_code, r.reason = + line:match("(HTTP/1.[01]) (%d%d%d)(.*)") + if not _ then + socket:close() + return nil, "http." .. method:lower() .. + ": Could not parse request line" + end + r.status_code = tonumber(r.status_code) + else + local sep = line:find(":") + local hdr_name = line:sub(1, sep-1):lower() + local hdr_val = line:sub(sep+1):match("^%s*(.*%S)%s*$") or "" + + if r.headers[hdr_name] == nil then + r.headers[hdr_name] = hdr_val + elseif type(r.headers[hdr_name]) == "table" then + table.insert(r.headers[hdr_name], hdr_val) + else + r.headers[hdr_name] = { + r.headers[hdr_name], + hdr_val + } + end + end + end + + if method:lower() == "head" then + r.content = nil + socket:close() + return r + end + + if r.headers["content-length"] and tonumber(r.headers["content-length"]) > 0 then + r.content, err = socket:receive("*a") + + if not r.content then + socket:close() + return nil, "http." .. method:lower() .. + ": Receive error (content): " .. err + end + end + + if r.headers["transfer-encoding"] and r.headers["transfer-encoding"] == "chunked" then + r.content, err = M.receive_chunked(socket) + if r.content == nil then + socket:close() + return nil, err + end + end + + socket:close() + return r + else + return nil, "http." .. method:lower() .. ": Connection error: " .. tostring(err) + end +end + +M.base64 = {} + +--- URL safe base64 encoder +-- +-- Padding ('=') is omited, as permited per RFC +-- https://tools.ietf.org/html/rfc4648 +-- in order to follow JSON Web Signature RFC +-- https://tools.ietf.org/html/rfc7515 +-- +-- @param s String (can be binary data) to encode +-- @param enc Function which implements base64 encoder (e.g. HAProxy base64 fetch) +-- @return Encoded string +function M.base64.encode(s, enc) + if not s then return nil end + local u = enc(s) + + if not u then + return nil + end + + local pad_len = 2 - ((#s-1) % 3) + + if pad_len > 0 then + return u:sub(1, - pad_len - 1):gsub('[+]', '-'):gsub('[/]', '_') + else + return u:gsub('[+]', '-'):gsub('[/]', '_') + end +end + +--- URLsafe base64 decoder +-- +-- @param s Base64 string to decode +-- @param dec Function which implements base64 decoder (e.g. HAProxy b64dec fetch) +-- @return Decoded string (can be binary data) +function M.base64.decode(s, dec) + if not s then return nil end + + local e = s:gsub('[-]', '+'):gsub('[_]', '/') + return dec(e .. string.rep('=', 3 - ((#s - 1) % 4))) +end + +return M \ No newline at end of file diff --git a/images/0.png b/images/0.png new file mode 100644 index 0000000..ee947cd Binary files /dev/null and b/images/0.png differ diff --git a/images/1.png b/images/1.png new file mode 100644 index 0000000..3635978 Binary files /dev/null and b/images/1.png differ diff --git a/images/10.png b/images/10.png new file mode 100644 index 0000000..497d6c8 Binary files /dev/null and b/images/10.png differ diff --git a/images/11.png b/images/11.png new file mode 100644 index 0000000..8343c99 Binary files /dev/null and b/images/11.png differ diff --git a/images/12.png b/images/12.png new file mode 100644 index 0000000..820b770 Binary files /dev/null and b/images/12.png differ diff --git a/images/13.png b/images/13.png new file mode 100644 index 0000000..7ca3ca0 Binary files /dev/null and b/images/13.png differ diff --git a/images/15.png b/images/15.png new file mode 100644 index 0000000..e9bbac6 Binary files /dev/null and b/images/15.png differ diff --git a/images/16.png b/images/16.png new file mode 100644 index 0000000..823edd3 Binary files /dev/null and b/images/16.png differ diff --git a/images/17.png b/images/17.png new file mode 100644 index 0000000..1321c15 Binary files /dev/null and b/images/17.png differ diff --git a/images/18.png b/images/18.png new file mode 100644 index 0000000..f85a571 Binary files /dev/null and b/images/18.png differ diff --git a/images/19.png b/images/19.png new file mode 100644 index 0000000..b338d21 Binary files /dev/null and b/images/19.png differ diff --git a/images/2.png b/images/2.png new file mode 100644 index 0000000..e768b54 Binary files /dev/null and b/images/2.png differ diff --git a/images/20.png b/images/20.png new file mode 100644 index 0000000..faa87f0 Binary files /dev/null and b/images/20.png differ diff --git a/images/21.png b/images/21.png new file mode 100644 index 0000000..e9dbe98 Binary files /dev/null and b/images/21.png differ diff --git a/images/22.png b/images/22.png new file mode 100644 index 0000000..4d32b98 Binary files /dev/null and b/images/22.png differ diff --git a/images/23.png b/images/23.png new file mode 100644 index 0000000..cc9d6e7 Binary files /dev/null and b/images/23.png differ diff --git a/images/24.png b/images/24.png new file mode 100644 index 0000000..3b64825 Binary files /dev/null and b/images/24.png differ diff --git a/images/25.png b/images/25.png new file mode 100644 index 0000000..3a26a15 Binary files /dev/null and b/images/25.png differ diff --git a/images/26.png b/images/26.png new file mode 100644 index 0000000..cbd7e23 Binary files /dev/null and b/images/26.png differ diff --git a/images/27.png b/images/27.png new file mode 100644 index 0000000..47b4b4f Binary files /dev/null and b/images/27.png differ diff --git a/images/28.png b/images/28.png new file mode 100644 index 0000000..1d505f6 Binary files /dev/null and b/images/28.png differ diff --git a/images/29.png b/images/29.png new file mode 100644 index 0000000..4707347 Binary files /dev/null and b/images/29.png differ diff --git a/images/3.png b/images/3.png new file mode 100644 index 0000000..9ebfa2f Binary files /dev/null and b/images/3.png differ diff --git a/images/30.png b/images/30.png new file mode 100644 index 0000000..9ceb923 Binary files /dev/null and b/images/30.png differ diff --git a/images/31.png b/images/31.png new file mode 100644 index 0000000..3e73144 Binary files /dev/null and b/images/31.png differ diff --git a/images/32.png b/images/32.png new file mode 100644 index 0000000..3f4c8a9 Binary files /dev/null and b/images/32.png differ diff --git a/images/33.png b/images/33.png new file mode 100644 index 0000000..9baa3f3 Binary files /dev/null and b/images/33.png differ diff --git a/images/34.png b/images/34.png new file mode 100644 index 0000000..38f336a Binary files /dev/null and b/images/34.png differ diff --git a/images/35.png b/images/35.png new file mode 100644 index 0000000..f632200 Binary files /dev/null and b/images/35.png differ diff --git a/images/36.png b/images/36.png new file mode 100644 index 0000000..96a1d18 Binary files /dev/null and b/images/36.png differ diff --git a/images/37.png b/images/37.png new file mode 100644 index 0000000..c26867b Binary files /dev/null and b/images/37.png differ diff --git a/images/38.png b/images/38.png new file mode 100644 index 0000000..8300926 Binary files /dev/null and b/images/38.png differ diff --git a/images/39.png b/images/39.png new file mode 100644 index 0000000..c517ddd Binary files /dev/null and b/images/39.png differ diff --git a/images/4.png b/images/4.png new file mode 100644 index 0000000..1f8152b Binary files /dev/null and b/images/4.png differ diff --git a/images/5.png b/images/5.png new file mode 100644 index 0000000..21abdb9 Binary files /dev/null and b/images/5.png differ diff --git a/images/6.png b/images/6.png new file mode 100644 index 0000000..286e8f1 Binary files /dev/null and b/images/6.png differ diff --git a/images/7.jpg b/images/7.jpg new file mode 100644 index 0000000..12cf0d0 Binary files /dev/null and b/images/7.jpg differ diff --git a/images/8.png b/images/8.png new file mode 100644 index 0000000..a1de301 Binary files /dev/null and b/images/8.png differ diff --git a/images/9.png b/images/9.png new file mode 100644 index 0000000..8910ae8 Binary files /dev/null and b/images/9.png differ diff --git a/json.lua b/json.lua new file mode 100644 index 0000000..54d4448 --- /dev/null +++ b/json.lua @@ -0,0 +1,388 @@ +-- +-- json.lua +-- +-- Copyright (c) 2020 rxi +-- +-- Permission is hereby granted, free of charge, to any person obtaining a copy of +-- this software and associated documentation files (the "Software"), to deal in +-- the Software without restriction, including without limitation the rights to +-- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +-- of the Software, and to permit persons to whom the Software is furnished to do +-- so, subject to the following conditions: +-- +-- The above copyright notice and this permission notice shall be included in all +-- copies or substantial portions of the Software. +-- +-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +-- SOFTWARE. +-- + +local json = { _version = "0.1.2" } + +------------------------------------------------------------------------------- +-- Encode +------------------------------------------------------------------------------- + +local encode + +local escape_char_map = { + [ "\\" ] = "\\", + [ "\"" ] = "\"", + [ "\b" ] = "b", + [ "\f" ] = "f", + [ "\n" ] = "n", + [ "\r" ] = "r", + [ "\t" ] = "t", +} + +local escape_char_map_inv = { [ "/" ] = "/" } +for k, v in pairs(escape_char_map) do + escape_char_map_inv[v] = k +end + + +local function escape_char(c) + return "\\" .. (escape_char_map[c] or string.format("u%04x", c:byte())) +end + + +local function encode_nil(val) + return "null" +end + + +local function encode_table(val, stack) + local res = {} + stack = stack or {} + + -- Circular reference? + if stack[val] then error("circular reference") end + + stack[val] = true + + if rawget(val, 1) ~= nil or next(val) == nil then + -- Treat as array -- check keys are valid and it is not sparse + local n = 0 + for k in pairs(val) do + if type(k) ~= "number" then + error("invalid table: mixed or invalid key types") + end + n = n + 1 + end + if n ~= #val then + error("invalid table: sparse array") + end + -- Encode + for i, v in ipairs(val) do + table.insert(res, encode(v, stack)) + end + stack[val] = nil + return "[" .. table.concat(res, ",") .. "]" + + else + -- Treat as an object + for k, v in pairs(val) do + if type(k) ~= "string" then + error("invalid table: mixed or invalid key types") + end + table.insert(res, encode(k, stack) .. ":" .. encode(v, stack)) + end + stack[val] = nil + return "{" .. table.concat(res, ",") .. "}" + end +end + + +local function encode_string(val) + return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"' +end + + +local function encode_number(val) + -- Check for NaN, -inf and inf + if val ~= val or val <= -math.huge or val >= math.huge then + error("unexpected number value '" .. tostring(val) .. "'") + end + return string.format("%.14g", val) +end + + +local type_func_map = { + [ "nil" ] = encode_nil, + [ "table" ] = encode_table, + [ "string" ] = encode_string, + [ "number" ] = encode_number, + [ "boolean" ] = tostring, +} + + +encode = function(val, stack) + local t = type(val) + local f = type_func_map[t] + if f then + return f(val, stack) + end + error("unexpected type '" .. t .. "'") +end + + +function json.encode(val) + return ( encode(val) ) +end + + +------------------------------------------------------------------------------- +-- Decode +------------------------------------------------------------------------------- + +local parse + +local function create_set(...) + local res = {} + for i = 1, select("#", ...) do + res[ select(i, ...) ] = true + end + return res +end + +local space_chars = create_set(" ", "\t", "\r", "\n") +local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",") +local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u") +local literals = create_set("true", "false", "null") + +local literal_map = { + [ "true" ] = true, + [ "false" ] = false, + [ "null" ] = nil, +} + + +local function next_char(str, idx, set, negate) + for i = idx, #str do + if set[str:sub(i, i)] ~= negate then + return i + end + end + return #str + 1 +end + + +local function decode_error(str, idx, msg) + local line_count = 1 + local col_count = 1 + for i = 1, idx - 1 do + col_count = col_count + 1 + if str:sub(i, i) == "\n" then + line_count = line_count + 1 + col_count = 1 + end + end + error( string.format("%s at line %d col %d", msg, line_count, col_count) ) +end + + +local function codepoint_to_utf8(n) + -- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa + local f = math.floor + if n <= 0x7f then + return string.char(n) + elseif n <= 0x7ff then + return string.char(f(n / 64) + 192, n % 64 + 128) + elseif n <= 0xffff then + return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128) + elseif n <= 0x10ffff then + return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128, + f(n % 4096 / 64) + 128, n % 64 + 128) + end + error( string.format("invalid unicode codepoint '%x'", n) ) +end + + +local function parse_unicode_escape(s) + local n1 = tonumber( s:sub(1, 4), 16 ) + local n2 = tonumber( s:sub(7, 10), 16 ) + -- Surrogate pair? + if n2 then + return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000) + else + return codepoint_to_utf8(n1) + end +end + + +local function parse_string(str, i) + local res = "" + local j = i + 1 + local k = j + + while j <= #str do + local x = str:byte(j) + + if x < 32 then + decode_error(str, j, "control character in string") + + elseif x == 92 then -- `\`: Escape + res = res .. str:sub(k, j - 1) + j = j + 1 + local c = str:sub(j, j) + if c == "u" then + local hex = str:match("^[dD][89aAbB]%x%x\\u%x%x%x%x", j + 1) + or str:match("^%x%x%x%x", j + 1) + or decode_error(str, j - 1, "invalid unicode escape in string") + res = res .. parse_unicode_escape(hex) + j = j + #hex + else + if not escape_chars[c] then + decode_error(str, j - 1, "invalid escape char '" .. c .. "' in string") + end + res = res .. escape_char_map_inv[c] + end + k = j + 1 + + elseif x == 34 then -- `"`: End of string + res = res .. str:sub(k, j - 1) + return res, j + 1 + end + + j = j + 1 + end + + decode_error(str, i, "expected closing quote for string") +end + + +local function parse_number(str, i) + local x = next_char(str, i, delim_chars) + local s = str:sub(i, x - 1) + local n = tonumber(s) + if not n then + decode_error(str, i, "invalid number '" .. s .. "'") + end + return n, x +end + + +local function parse_literal(str, i) + local x = next_char(str, i, delim_chars) + local word = str:sub(i, x - 1) + if not literals[word] then + decode_error(str, i, "invalid literal '" .. word .. "'") + end + return literal_map[word], x +end + + +local function parse_array(str, i) + local res = {} + local n = 1 + i = i + 1 + while 1 do + local x + i = next_char(str, i, space_chars, true) + -- Empty / end of array? + if str:sub(i, i) == "]" then + i = i + 1 + break + end + -- Read token + x, i = parse(str, i) + res[n] = x + n = n + 1 + -- Next token + i = next_char(str, i, space_chars, true) + local chr = str:sub(i, i) + i = i + 1 + if chr == "]" then break end + if chr ~= "," then decode_error(str, i, "expected ']' or ','") end + end + return res, i +end + + +local function parse_object(str, i) + local res = {} + i = i + 1 + while 1 do + local key, val + i = next_char(str, i, space_chars, true) + -- Empty / end of object? + if str:sub(i, i) == "}" then + i = i + 1 + break + end + -- Read key + if str:sub(i, i) ~= '"' then + decode_error(str, i, "expected string for key") + end + key, i = parse(str, i) + -- Read ':' delimiter + i = next_char(str, i, space_chars, true) + if str:sub(i, i) ~= ":" then + decode_error(str, i, "expected ':' after key") + end + i = next_char(str, i + 1, space_chars, true) + -- Read value + val, i = parse(str, i) + -- Set + res[key] = val + -- Next token + i = next_char(str, i, space_chars, true) + local chr = str:sub(i, i) + i = i + 1 + if chr == "}" then break end + if chr ~= "," then decode_error(str, i, "expected '}' or ','") end + end + return res, i +end + + +local char_func_map = { + [ '"' ] = parse_string, + [ "0" ] = parse_number, + [ "1" ] = parse_number, + [ "2" ] = parse_number, + [ "3" ] = parse_number, + [ "4" ] = parse_number, + [ "5" ] = parse_number, + [ "6" ] = parse_number, + [ "7" ] = parse_number, + [ "8" ] = parse_number, + [ "9" ] = parse_number, + [ "-" ] = parse_number, + [ "t" ] = parse_literal, + [ "f" ] = parse_literal, + [ "n" ] = parse_literal, + [ "[" ] = parse_array, + [ "{" ] = parse_object, +} + + +parse = function(str, idx) + local chr = str:sub(idx, idx) + local f = char_func_map[chr] + if f then + return f(str, idx) + end + decode_error(str, idx, "unexpected character '" .. chr .. "'") +end + + +function json.decode(str) + if type(str) ~= "string" then + error("expected argument of type string, got " .. type(str)) + end + local res, idx = parse(str, next_char(str, 1, space_chars, true)) + idx = next_char(str, idx, space_chars, true) + if idx <= #str then + decode_error(str, idx, "trailing garbage") + end + return res +end + + +return json \ No newline at end of file