From eaa5cfef6f94d56344f7bbde02c8c76dacf4d422 Mon Sep 17 00:00:00 2001 From: "R. Tyler Croy" Date: Fri, 30 May 2014 16:15:20 -0700 Subject: [PATCH] Initial open source commit --- .gitignore | 6 + .gitmodules | 18 + Gemfile | 8 + LICENSE.txt | 22 + Makefile | 149 ++++ README.md | 121 +++ config | 1 + contrib/nginx-sticky-module/LICENSE | 24 + contrib/nginx-sticky-module/README | 129 ++++ contrib/nginx-sticky-module/config | 6 + contrib/nginx-sticky-module/docs/sticky.pdf | Bin 0 -> 81907 bytes contrib/nginx-sticky-module/docs/sticky.vsd | Bin 0 -> 54272 bytes .../ngx_http_sticky_misc.c | 315 ++++++++ .../ngx_http_sticky_misc.h | 28 + .../ngx_http_sticky_module.c | 691 ++++++++++++++++++ pkg/after-install.sh | 7 + pkg/borderpatrol.conf | 9 + pkg/borderpatrol.init | 148 ++++ src/access.lua | 16 + src/authorize.lua | 91 +++ src/config/nginx.conf.sample | 151 ++++ src/health_check.lua | 55 ++ src/logout.lua | 37 + src/redirect.lua | 25 + src/robots.txt | 2 + src/service_token.lua | 49 ++ src/sessionid.lua | 275 +++++++ src/ssl/README.md | 11 + src/ssl/server.crt | 15 + src/ssl/server.key | 15 + src/validate.lua | 83 +++ t/.gitkeep | 0 t/access.t | 91 +++ t/authorize.t | 271 +++++++ t/borderpatrol.god | 69 ++ t/health.t | 33 + t/logout.t | 77 ++ t/redirect.t | 105 +++ t/service_token.t | 166 +++++ t/services/account_service.rb | 89 +++ t/services/api_service.rb | 48 ++ t/services/api_service2.rb | 48 ++ t/services/auth_service.rb | 49 ++ t/session.t | 128 ++++ t/validate.t | 161 ++++ 45 files changed, 3842 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 Gemfile create mode 100644 LICENSE.txt create mode 100644 Makefile create mode 100644 README.md create mode 100644 config create mode 100644 contrib/nginx-sticky-module/LICENSE create mode 100644 contrib/nginx-sticky-module/README create mode 100644 contrib/nginx-sticky-module/config create mode 100644 contrib/nginx-sticky-module/docs/sticky.pdf create mode 100644 contrib/nginx-sticky-module/docs/sticky.vsd create mode 100644 contrib/nginx-sticky-module/ngx_http_sticky_misc.c create mode 100644 contrib/nginx-sticky-module/ngx_http_sticky_misc.h create mode 100644 contrib/nginx-sticky-module/ngx_http_sticky_module.c create mode 100644 pkg/after-install.sh create mode 100644 pkg/borderpatrol.conf create mode 100644 pkg/borderpatrol.init create mode 100644 src/access.lua create mode 100644 src/authorize.lua create mode 100644 src/config/nginx.conf.sample create mode 100644 src/health_check.lua create mode 100644 src/logout.lua create mode 100644 src/redirect.lua create mode 100644 src/robots.txt create mode 100644 src/service_token.lua create mode 100644 src/sessionid.lua create mode 100644 src/ssl/README.md create mode 100644 src/ssl/server.crt create mode 100644 src/ssl/server.key create mode 100644 src/validate.lua create mode 100644 t/.gitkeep create mode 100644 t/access.t create mode 100644 t/authorize.t create mode 100644 t/borderpatrol.god create mode 100644 t/health.t create mode 100644 t/logout.t create mode 100644 t/redirect.t create mode 100644 t/service_token.t create mode 100644 t/services/account_service.rb create mode 100644 t/services/api_service.rb create mode 100644 t/services/api_service2.rb create mode 100644 t/services/auth_service.rb create mode 100644 t/session.t create mode 100644 t/validate.t diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..897fae4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +tmp +.rvmrc +Gemfile.lock +logs +build +t/servroot diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..16d3933 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,18 @@ +[submodule "contrib/nginx"] + path = contrib/nginx + url = https://github.com/nginx/nginx.git +[submodule "contrib/ngx_devel_kit"] + path = contrib/ngx_devel_kit + url = https://github.com/simpl/ngx_devel_kit.git +[submodule "contrib/lua-nginx-module"] + path = contrib/lua-nginx-module + url = https://github.com/chaoslawful/lua-nginx-module.git +[submodule "contrib/memc-nginx-module"] + path = contrib/memc-nginx-module + url = https://github.com/agentzh/memc-nginx-module.git +[submodule "contrib/echo-nginx-module"] + path = contrib/echo-nginx-module + url = https://github.com/agentzh/echo-nginx-module.git +[submodule "contrib/headers-more-nginx-module"] + path = contrib/headers-more-nginx-module + url = https://github.com/agentzh/headers-more-nginx-module.git diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..4f9659d --- /dev/null +++ b/Gemfile @@ -0,0 +1,8 @@ +source 'https://rubygems.org/' + +gem 'sinatra' +gem 'httparty' +gem 'god' +gem 'haml' +# For reloading every request to the sinatra test apps +gem 'shotgun' diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..e9fbb81 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,22 @@ +Copyright (c) 2014 Lookout, Inc + +MIT License + +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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e672750 --- /dev/null +++ b/Makefile @@ -0,0 +1,149 @@ +# Debian package name & version +MAJOR_VER=0 +MINOR_VER=1 +PATCH_VER=0 +PKG_NAME=borderpatrol +BUILD_VER=0${BUILD_NUMBER}-dev + +# binaries +CC:=clang +LUAROCKS=luarocks + +# nginx and lua modules +MODULE_PATH=${PWD} +MODULE_PKG_DIR=${MODULE_PATH}/pkg +CONTRIB_PATH=${MODULE_PATH}/contrib +NGINX_PATH=${CONTRIB_PATH}/nginx +NDK_PATH=${CONTRIB_PATH}/ngx_devel_kit +MEMC_NGINX_PATH=${CONTRIB_PATH}/memc-nginx-module +LUA_MODULE_PATH=${CONTRIB_PATH}/lua-nginx-module +ECHO_MODULE_PATH=${CONTRIB_PATH}/echo-nginx-module +STICKY_MODULE_PATH=${CONTRIB_PATH}/nginx-sticky-module +HEADERS_MORE_MODULE_PATH=${CONTRIB_PATH}/headers-more-nginx-module + +NGINX_MODULES=--add-module=${NDK_PATH} \ + --add-module=${MEMC_NGINX_PATH} \ + --add-module=${LUA_MODULE_PATH} \ + --add-module=${STICKY_MODULE_PATH} \ + --add-module=${HEADERS_MORE_MODULE_PATH} \ + --add-module=${ECHO_MODULE_PATH} # only needed for + +# build locations +BUILD_PATH=${MODULE_PATH}/build +DESTDIR=${PWD}/${PKG_NAME} + +# packaging locations +CONF_DIR=/etc/${PKG_NAME} +SBIN_DIR=/usr/sbin +LOG_DIR=/var/log/${PKG_NAME} +SHARE_DIR=/usr/share/${PKG_NAME} + +# test locations +TEST_DIR = ${MODULE_PATH}/t +TEST_RUN_DIR = ${TEST_DIR}/servroot + +UNAME:=$(shell uname -s) + +ifeq ($(UNAME), Darwin) +CFLAGS+="-I /usr/local/include -Wno-error" +LD_FLAGS+="-L /usr/local/lib -L /usr/lib -liconv" +endif + +all: build + +$(BUILD_PATH)/.install_rocks: + @$(LUAROCKS) install luajson --to=$(BUILD_PATH)/usr + @$(LUAROCKS) install luacrypto --to=$(BUILD_PATH)/usr + @touch $(BUILD_PATH)/.install_rocks + +build: submodules compile mkdirs $(BUILD_PATH)/.install_rocks + @cp ${NGINX_PATH}/objs/nginx ${BUILD_PATH}${SBIN_DIR}/${PKG_NAME} + @cp -rp ${PWD}/src/*.lua ${BUILD_PATH}${SHARE_DIR} + @cp ${PWD}/src/robots.txt ${BUILD_PATH}${SHARE_DIR} + @cp ${PWD}/src/config/nginx.conf.sample ${BUILD_PATH}${CONF_DIR}/sites-available/${PKG_NAME}.conf.sample + @cp ${PWD}/src/ssl/server.crt ${BUILD_PATH}${CONF_DIR}/ssl/server.crt + @cp ${PWD}/src/ssl/server.key ${BUILD_PATH}${CONF_DIR}/ssl/server.key + +submodules: + git submodule init + git submodule update + +mkdirs: + @mkdir -p ${BUILD_PATH}${CONF_DIR}/conf.d + @mkdir -p ${BUILD_PATH}${CONF_DIR}/sites-available + @mkdir -p ${BUILD_PATH}${CONF_DIR}/sites-enabled + @mkdir -p ${BUILD_PATH}${CONF_DIR}/ssl + @mkdir -p ${BUILD_PATH}${SHARE_DIR} + @mkdir -p ${BUILD_PATH}${CONF_DIR} + @mkdir -p ${BUILD_PATH}${SBIN_DIR} + +compile: + @if [ ! -f ${NGINX_PATH}/Makefile ]; then (cd ${NGINX_PATH} && \ + ./configure --prefix=/usr \ + --sbin-path=${SBIN_DIR}/borderpatrol \ + --conf-path=${CONF_DIR}/borderpatrol.conf \ + --pid-path=/var/run/borderpatrol.pid \ + --error-log-path=${LOG_DIR}/error.log \ + --http-log-path=${LOG_DIR}/access.log \ + ${NGINX_MODULES} \ + --with-ld-opt=${LD_FLAGS} \ + --with-cc-opt=${CFLAGS} \ + --with-http_ssl_module); \ + fi; + @(cd ${NGINX_PATH} && make -j2) + +.PHONY : test + +test: build + @TEST_NGINX_BINARY=${PKG_NAME} PATH=${BUILD_PATH}${SBIN_DIR}:${PATH} prove -r ${TEST_DIR}/*.t + +mocktest: build + god -Dbc t/borderpatrol.god + +make clean: + rm -rf ${BUILD_PATH} + rm -rf ${DESTDIR} + rm -rf ngx_borderpatrol* + rm -rf *.deb + +distclean: clean + (cd ${NGINX_PATH} && if [ -f Makefile ]; then make clean; fi;) + +pkg: test + + # copy the build target dir to the package dir + rm -rf ${DESTDIR} + mv ${BUILD_PATH} ${DESTDIR} + + # Install configs under /etc/borderpatrol + cp ${MODULE_PKG_DIR}/borderpatrol.conf ${DESTDIR}${CONF_DIR}/borderpatrol.conf + chmod 0600 ${DESTDIR}${CONF_DIR}/borderpatrol.conf + chmod 0600 ${DESTDIR}${CONF_DIR}/sites-available/* + + # Install package hooks + cp ${MODULE_PKG_DIR}/after-install.sh ${DESTDIR} + + # Setup upstart config + mkdir -p ${DESTDIR}/etc/init.d + cp ${MODULE_PKG_DIR}/borderpatrol.init ${DESTDIR}/etc/init.d/borderpatrol + chmod 755 ${DESTDIR}/etc/init.d/borderpatrol + + # Create extra directories + mkdir -p ${DESTDIR}/var/log/borderpatrol + mkdir -p ${DESTDIR}/var/borderpatrol + mkdir -p ${DESTDIR}/var/cache/borderpatrol + + ########################################################################## + # create the borderpatrol package + + # install fpm if needed + test -n "$(shell gem query --local fpm|grep fpm)" || gem install fpm + + cd ${DESTDIR} && fpm -s dir -t deb -n borderpatrol -v ${MAJOR_VER}.${MINOR_VER}.${PATCH_VER}-${BUILD_VER} -C ${DESTDIR} \ + -p borderpatrol-VERSION_ARCH.deb \ + --after-install after-install.sh \ + -d libssl1.0.0 \ + -d luarocks \ + usr/ etc/ var/ + + mv ${DESTDIR}/*.deb ${PWD}/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..9475c78 --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# BorderPatrol for Nginx + +BorderPatrol is an nginx module to perform authentication and session management at the border of your network. + +BorderPatrol makes the assumption that you have some set of services that require authentication and a service that +hands out tokens to clients to access that service. You may not want those tokens to be sent across the internet, even +over SSL, for a variety of reasons. To this end, BorderPatrol maintains a lookup table of session-id to auth token +in memcached. + +## Overview Diagram + + +-------------+ + | BROWSER | + +--+----------+ + | ^ + REQ | | RESP + | | + v | SVC + +------------+----+ CALL +-------------------------------+ + | +------->| SERVICE A REQUIRING | + | |<-------| AUTHENTICATION | + | | +-------------------------------+ + | NGINX | + | | +-------------------------------+ + | +------->| SERVICE B REQUIRING | + | |<-------| AUTHENTICATION | + +-----------------+ +-------------------------------+ + | ^ | ^ + CACHE | | | | AUTH + LOOKUP | | | | LOOKUP + v | v | + +-----------+-+ +---+----------+ + | SESSION | | AUTH | + | STORE | | SERVICE | + +-------------+ +--------------+ + +## Use cases + +**Assumption:** All content to be access via BorderPatrol requires authentication + +There are three primary use cases for BorderPatrol: + +* A client has an auth token in the session store and the request is forwarded to the downstream service -or- +* A client does not have an auth_token for the specified service but has a master token, a call to the auth service will be made to get a service token for the downstream service -or- +* A client does not have an auth_token, and the client is redirected to a login page which posts back to nginx, performs an auth service lookup (and returns a master token and a service token from the auth service) and, on success, creates an entry in the session store for subsequent requests. + +### Use Case 1: Authorized Access + +* Client requests a protected resource via BorderPatrol +* BorderPatrol looks up the session_id from the HTTP request in the SessionStore +* If service token present, BorderPatrol sets the Auth-Token header to the service token and allows the request to continue to the protected resource + +### Use Case 2: Unauthorized Access + +* Client requests a protected resource via BorderPatrol +* BorderPatrol looks up the session_id from the HTTP request in the SessionStore +* Record exists in cache and there is a master token but no service token for specified downstream service +* A call is made to the Auth Service using the master token to get a service token +* BorderPatrol updates the session_id/{master_token, service_token_1, service_token_2...} pair in the SessionStore with appropriate expiry +* BorderPatrol redirects with the appropriate service Auth-Token header to the protected resource + +### Use Case 3: Unauthorized Access + +* Client requests a protected resource via BorderPatrol +* BorderPatrol looks up the session_id from the HTTP request in the SessionStore +* If there is a cache miss, BorderPatrol serves up a login page +* On submittal, this posts to the AuthService (via BorderPatrol) +* On successful authentication (which returns a master token and a service token for the downstream service), the AuthService sets the Auth-Token header +* BorderPatrol sets the session_id/{master_token, service_token} pair in the SessionStore with appropriate expiry +* BorderPatrol redirects with the appropriate service Auth-Token header to the protected resource + +### Caching detail + +The tokens cached in the session store are a string representation of a JSON structure as follows. + +{ + "master_token" : "MMM", + "service_tokens" : { "service_a": "AAA", "service_b": "BBB" } +} + +The token that has the key of 'master_token' is the Master Token, and can be used to make a call to the Auth Service to get other service tokens. +Service Tokens have a key name that corresponds to the name of the downstream service. + + +### Installation + +#### Darwin + +* get homebrew (http://mxcl.github.io/homebrew/) +* brew install luarocks +* brew install pcre +* brew install lua +* brew install luajit +* make + +#### Linux +* apt-get install luarocks +* make + +### Running unit tests + +You'll need the Test::Nginx CPAN module. + +* cpan install Test::Nginx +* make test + +### Running full mock services locally + +* bundle install +* make mocktest +* In a browser, hit https://localhost:4443/b/ + +### Additional Notes + +make mocktest uses God to run 4 processes, on the following ports +4443 Mock BorderPatrol +9081 Mock Authorization service +9082 Mock downstream service A +9083 Mock downstream service B + +Once you stop Mocktest, manually kill the processes above diff --git a/config b/config new file mode 100644 index 0000000..f2c9dcf --- /dev/null +++ b/config @@ -0,0 +1 @@ +have=NDK_HTTP . auto/have diff --git a/contrib/nginx-sticky-module/LICENSE b/contrib/nginx-sticky-module/LICENSE new file mode 100644 index 0000000..0654c57 --- /dev/null +++ b/contrib/nginx-sticky-module/LICENSE @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2010 Jerome Loyet (jerome at loyet dot net) + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY AUTHOR AND CONTRIBUTORS ``AS IS'' AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL AUTHOR OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + * SUCH DAMAGE. + */ diff --git a/contrib/nginx-sticky-module/README b/contrib/nginx-sticky-module/README new file mode 100644 index 0000000..77bff37 --- /dev/null +++ b/contrib/nginx-sticky-module/README @@ -0,0 +1,129 @@ +Nginx Sticky Module +-- + +Description: + A nginx module to add a sticky cookie to be always forwarded the the same + upstream server. + + When dealing with several backend servers, it's sometimes useful that one + client (browser) is always served by the same backend server + (for session persistance for example). + + Using a persistance by IP (with the ip_hash upstream module) is maybe not + a good idea because there could be situations where a lot of different + browsers are coming with the same IP address (behind proxies)and the load + balancing system won't be fair. + + Using a cookie to track the upstream server makes each browser unique. + + When the sticky module can't apply, it switchs back to the classic Round Robin + Upstream or returns a "Bad Gateway" (depending on the no_fallback flag). + + Sticky module can't apply when cookies are not supported by the browser + + * Sticky module is based on a "best effort" algorithm. Its aim is not to handle + * security somehow. It's been made to ensure that normal users are always + * redirected to the same backend server: that's all! + +Installation + + You'll need to re-compile Nginx from source to include this module. + Modify your compile of Nginx by adding the following directive + (modified to suit your path of course): + + ./configure ... --add-module=/absolute/path/to/nginx-sticky-module + make + make install + +Usage + upstream { + sticky; + server 127.0.0.1:9000; + server 127.0.0.1:9001; + server 127.0.0.1:9002; + } + + sticky [name=route] [domain=.foo.bar] [path=/] [expires=1h] [hash=index|md5|sha1] [no_fallback]; + - name: the name of the cookies used to track the persistant upstream srv + default: route + + - domain: the domain in which the cookie will be valid + default: nothing. Let the browser handle this. + + - path: the path in which the cookie will be valid + default: nothing. Let the browser handle this. + + - expires: the validity duration of the cookie + default: nothing. It's a session cookie. + restriction: must be a duration greater than one second + + - hash: the hash mechanism to encode upstream server. It cant' be used + with hmac. + md5|sha1: well known hash + index: it's not hashed, an in-memory index is used instead + it's quicker and the overhead is shorter + Warning: the matching against upstream servers list + is inconsistent. So, at reload, if upstreams servers + has changed, index values are not guaranted to + correspond to the same server as before! + USE IT WITH CAUTION and only if you need to! + default: md5 + + - hmac: the HMAC hash mechanism to encode upstream server + It's like the hash mechanism but it uses hmac_key + to secure the hashing. It can't be used with hash. + md5|sha1: well known hash + default: none. see hash. + + -hmac_key: the key to use with hmac. It's mandatory when hmac is set + default: nothing. + + -no_fallback: when this flag is set, nginx will return a 502 (Bad Gateway or + Proxy Error) if a request comes with a cookie and the + corresponding backend is unavailable. + +Detail Mechanism + see docs/sticky.{vsd,pdf} + +Warnings: + - sticky module does not work with the "backup" option of the "server" configuration item. + - sticky module does not work with the nginx_http_upstream_check_module. + - sticky module may require to configure nginx with SSL support. + +Contributing + http://code.google.com/p/nginx-sticky-module/ + +TODO + Stress + Code review + +Author + Jerome Loyet + +Copyright & License + This module is licenced under the BSD license. + + Copyright (C) 2010 Jerome Loyet (jerome at loyet dot net) + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY AUTHOR AND CONTRIBUTORS ``AS IS'' AND + ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL AUTHOR OR CONTRIBUTORS BE LIABLE + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + SUCH DAMAGE. diff --git a/contrib/nginx-sticky-module/config b/contrib/nginx-sticky-module/config new file mode 100644 index 0000000..bf9bacc --- /dev/null +++ b/contrib/nginx-sticky-module/config @@ -0,0 +1,6 @@ +ngx_addon_name=ngx_http_sticky_module +HTTP_MODULES="$HTTP_MODULES ngx_http_sticky_module" +NGX_ADDON_SRCS="$NGX_ADDON_SRCS $ngx_addon_dir/ngx_http_sticky_module.c $ngx_addon_dir/ngx_http_sticky_misc.c" +NGX_ADDON_DEPS="$NGX_ADDON_DEPS $ngx_addon_dir/ngx_http_sticky_misc.h" +USE_MD5=YES +USE_SHA1=YES diff --git a/contrib/nginx-sticky-module/docs/sticky.pdf b/contrib/nginx-sticky-module/docs/sticky.pdf new file mode 100644 index 0000000000000000000000000000000000000000..ae74c1d0127049ec83e0d9de2d87604d9e61b3da GIT binary patch literal 81907 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 z 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%0A*v~MH`4W0~*M9NyxSg}_oi?dd@ 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�?zToitwDfvx8(_lTMwiiY6$`9?t8?ZBcvFRAzhRs&Kw~(05YHp zNdY9I2NJPdNuCA7zN^eD`3bTJ5T`C=DwJslgxiJW6Q{|+%McpWg|x>WCMN=dN82c$ zW53EsAs}=>jH8oyMMehoKwgc{BIg0pEscCYk^t$ZOn1m4clIcAY*ZF`wnv#)*JNZN zAl+@9fwu1TK=RG9$cKP*kManYED};Jeu3Nto*`)93p#xQ&XT=*AY&A#$bNuy>(7p~ zQ{)stMs)T4ar!B8F(BM7Wk#IY}?Fcv4kx_6%q_W{!^mVO1o_J7Gf8%vS+f0~b zlVSQ^iNj$bA3h3K;aS)ajt|LU;%!laEzHfUku7ae+L!fUsqCx=S<|8J>i4+c5w-d# zl=x-{ien<4Jv>-?Y7iucK;hQN*%U>;9}To5*02UFJ2eD3k46$x#ABoAkJc#K15&$> zN8?5r!b5y7J4(VT50U?c`V@u>gLUIHNhlVt`O4UGB0=fMBj=Kc?;3!sjbRR(zz zICO{4Mh<%zsC)eJe@Bj9z}Z{i(joRsJ2-&<&s>>%_slT|bNIIDke$j4yCfYPhRBNYCFjl+h`#8Xzm zJcObK*mSrIV8c5Lmr$5&&>qrf=BF1 Pd$a}5xc|ET+Y +#include +#include +#include +#include +#include + +#include "ngx_http_sticky_misc.h" + +#ifndef ngx_str_set + #define ngx_str_set(str, text) (str)->len = sizeof(text) - 1; (str)->data = (u_char *) text +#endif + +ngx_int_t ngx_http_sticky_misc_set_cookie(ngx_http_request_t *r, ngx_str_t *name, ngx_str_t *value, ngx_str_t *domain, ngx_str_t *path, time_t expires) +{ + u_char *cookie, *p; + size_t len; + ngx_table_elt_t *set_cookie, *elt; + ngx_str_t remove; + ngx_list_part_t *part; + ngx_uint_t i; + + if (value == NULL) { + ngx_str_set(&remove, "_remove_"); + value = &remove; + } + + /* name = value */ + len = name->len + 1 + value->len; + + /*; Domain= */ + if (domain->len > 0) { + len += sizeof("; Domain=") - 1 + domain->len; + } + + /*; Max-Age= */ + if (expires != NGX_CONF_UNSET) { + len += sizeof("; Max-Age=") - 1 + NGX_TIME_T_LEN; + } + + /* ; Path= */ + if (path->len > 0) { + len += sizeof("; Path=") - 1 + path->len; + } + + cookie = ngx_pnalloc(r->pool, len); + if (cookie == NULL) { + return NGX_ERROR; + } + + p = ngx_copy(cookie, name->data, name->len); + *p++ = '='; + p = ngx_copy(p, value->data, value->len); + + if (domain->len > 0) { + p = ngx_copy(p, "; Domain=", sizeof("; Domain=") - 1); + p = ngx_copy(p, domain->data, domain->len); + } + + if (expires != NGX_CONF_UNSET) { + p = ngx_copy(p, "; Max-Age=", sizeof("; Max-Age=") - 1); + p = ngx_snprintf(p, NGX_TIME_T_LEN, "%T", expires); + } + + if (path->len > 0) { + p = ngx_copy(p, "; Path=", sizeof("; Path=") - 1); + p = ngx_copy(p, path->data, path->len); + } + + part = &r->headers_out.headers.part; + elt = part->elts; + set_cookie = NULL; + + for (i=0 ;; i++) { + if (part->nelts > 1 || i >= part->nelts) { + if (part->next == NULL) { + break; + } + part = part->next; + elt = part->elts; + i = 0; + } + /* ... */ + if (ngx_strncmp(elt->value.data, name->data, name->len) == 0) { + set_cookie = elt; + break; + } + } + + /* found a Set-Cookie header with the same name: replace it */ + if (set_cookie != NULL) { + set_cookie->value.len = p - cookie; + set_cookie->value.data = cookie; + return NGX_OK; + } + + set_cookie = ngx_list_push(&r->headers_out.headers); + if (set_cookie == NULL) { + return NGX_ERROR; + } + set_cookie->hash = 1; + ngx_str_set(&set_cookie->key, "Set-Cookie"); + set_cookie->value.len = p - cookie; + set_cookie->value.data = cookie; + + return NGX_OK; +} + +ngx_int_t ngx_http_sticky_misc_md5(ngx_pool_t *pool, void *in, size_t len, ngx_str_t *digest) +{ + ngx_md5_t md5; + u_char hash[MD5_DIGEST_LENGTH]; + + digest->data = ngx_pcalloc(pool, MD5_DIGEST_LENGTH * 2); + if (digest->data == NULL) { + return NGX_ERROR; + } + + digest->len = MD5_DIGEST_LENGTH * 2; + ngx_md5_init(&md5); + ngx_md5_update(&md5, in, len); + ngx_md5_final(hash, &md5); + + ngx_hex_dump(digest->data, hash, MD5_DIGEST_LENGTH); + return NGX_OK; +} + +ngx_int_t ngx_http_sticky_misc_sha1(ngx_pool_t *pool, void *in, size_t len, ngx_str_t *digest) +{ + ngx_sha1_t sha1; + u_char hash[SHA_DIGEST_LENGTH]; + + digest->data = ngx_pcalloc(pool, SHA_DIGEST_LENGTH * 2); + if (digest->data == NULL) { + return NGX_ERROR; + } + + digest->len = SHA_DIGEST_LENGTH * 2; + ngx_sha1_init(&sha1); + ngx_sha1_update(&sha1, in, len); + ngx_sha1_final(hash, &sha1); + + ngx_hex_dump(digest->data, hash, SHA_DIGEST_LENGTH); + return NGX_OK; +} + +ngx_int_t ngx_http_sticky_misc_hmac_md5(ngx_pool_t *pool, void *in, size_t len, ngx_str_t *key, ngx_str_t *digest) +{ + u_char hash[MD5_DIGEST_LENGTH]; + u_char k[MD5_CBLOCK]; + ngx_md5_t md5; + u_int i; + + digest->data = ngx_pcalloc(pool, MD5_DIGEST_LENGTH * 2); + if (digest->data == NULL) { + return NGX_ERROR; + } + digest->len = MD5_DIGEST_LENGTH * 2; + + ngx_memzero(k, sizeof(k)); + + if (key->len > MD5_CBLOCK) { + ngx_md5_init(&md5); + ngx_md5_update(&md5, key->data, key->len); + ngx_md5_final(k, &md5); + } else { + ngx_memcpy(k, key->data, key->len); + } + + /* XOR ipad */ + for (i=0; i < MD5_CBLOCK; i++) { + k[i] ^= 0x36; + } + + ngx_md5_init(&md5); + ngx_md5_update(&md5, k, MD5_CBLOCK); + ngx_md5_update(&md5, in, len); + ngx_md5_final(hash, &md5); + + /* Convert k to opad -- 0x6A = 0x36 ^ 0x5C */ + for (i=0; i < MD5_CBLOCK; i++) { + k[i] ^= 0x6a; + } + + ngx_md5_init(&md5); + ngx_md5_update(&md5, k, MD5_CBLOCK); + ngx_md5_update(&md5, hash, MD5_DIGEST_LENGTH); + ngx_md5_final(hash, &md5); + + ngx_hex_dump(digest->data, hash, MD5_DIGEST_LENGTH); + + return NGX_OK; +} + +ngx_int_t ngx_http_sticky_misc_hmac_sha1(ngx_pool_t *pool, void *in, size_t len, ngx_str_t *key, ngx_str_t *digest) +{ + u_char hash[SHA_DIGEST_LENGTH]; + u_char k[SHA_CBLOCK]; + ngx_sha1_t sha1; + u_int i; + + digest->data = ngx_pcalloc(pool, SHA_DIGEST_LENGTH * 2); + if (digest->data == NULL) { + return NGX_ERROR; + } + digest->len = SHA_DIGEST_LENGTH * 2; + + ngx_memzero(k, sizeof(k)); + + if (key->len > SHA_CBLOCK) { + ngx_sha1_init(&sha1); + ngx_sha1_update(&sha1, key->data, key->len); + ngx_sha1_final(k, &sha1); + } else { + ngx_memcpy(k, key->data, key->len); + } + + /* XOR ipad */ + for (i=0; i < SHA_CBLOCK; i++) { + k[i] ^= 0x36; + } + + ngx_sha1_init(&sha1); + ngx_sha1_update(&sha1, k, SHA_CBLOCK); + ngx_sha1_update(&sha1, in, len); + ngx_sha1_final(hash, &sha1); + + /* Convert k to opad -- 0x6A = 0x36 ^ 0x5C */ + for (i=0; i < SHA_CBLOCK; i++) { + k[i] ^= 0x6a; + } + + ngx_sha1_init(&sha1); + ngx_sha1_update(&sha1, k, SHA_CBLOCK); + ngx_sha1_update(&sha1, hash, SHA_DIGEST_LENGTH); + ngx_sha1_final(hash, &sha1); + + ngx_hex_dump(digest->data, hash, SHA_DIGEST_LENGTH); + + return NGX_OK; +} + +ngx_int_t ngx_http_sticky_misc_text_raw(ngx_pool_t *pool, struct sockaddr *in, ngx_str_t *digest) +{ + size_t len; + if (!in) { + return NGX_ERROR; + } + + switch (in->sa_family) { + case AF_INET: + len = NGX_INET_ADDRSTRLEN + sizeof(":65535") - 1; + break; + +#if (NGX_HAVE_INET6) + case AF_INET6: + len = NGX_INET6_ADDRSTRLEN + sizeof(":65535") - 1; + break; +#endif + +#if (NGX_HAVE_UNIX_DOMAIN) + case AF_UNIX: + len = sizeof("unix:") - 1 + NGX_UNIX_ADDRSTRLEN; + break; +#endif + + default: + return NGX_ERROR; + } + + + digest->data = ngx_pnalloc(pool, len); + if (digest->data == NULL) { + return NGX_ERROR; + } + digest->len = ngx_sock_ntop(in, digest->data, len, 1); + return NGX_OK; + return NGX_OK; +} + +ngx_int_t ngx_http_sticky_misc_text_md5(ngx_pool_t *pool, struct sockaddr *in, ngx_str_t *digest) +{ + ngx_str_t str; + if (ngx_http_sticky_misc_text_raw(pool, in, &str) != NGX_OK) { + return NGX_ERROR; + } + + if (ngx_http_sticky_misc_md5(pool, (void *)str.data, str.len, digest) != NGX_OK) { + ngx_pfree(pool, &str); + return NGX_ERROR; + } + + return ngx_pfree(pool, &str); +} + +ngx_int_t ngx_http_sticky_misc_text_sha1(ngx_pool_t *pool, struct sockaddr *in, ngx_str_t *digest) +{ + ngx_str_t str; + if (ngx_http_sticky_misc_text_raw(pool, in, &str) != NGX_OK) { + return NGX_ERROR; + } + + if (ngx_http_sticky_misc_sha1(pool, (void *)str.data, str.len, digest) != NGX_OK) { + ngx_pfree(pool, &str); + return NGX_ERROR; + } + + return ngx_pfree(pool, &str); +} + diff --git a/contrib/nginx-sticky-module/ngx_http_sticky_misc.h b/contrib/nginx-sticky-module/ngx_http_sticky_misc.h new file mode 100644 index 0000000..fe8e859 --- /dev/null +++ b/contrib/nginx-sticky-module/ngx_http_sticky_misc.h @@ -0,0 +1,28 @@ + +/* + * Copyright (C) 2010 Jerome Loyet (jerome at loyet dot net) + */ + +#ifndef _NGX_HTTP_STICKY_MISC_H_INCLUDED_ +#define _NGX_HTTP_STICKY_MISC_H_INCLUDED_ + +#include +#include +#include +#include + +typedef ngx_int_t (*ngx_http_sticky_misc_hash_pt)(ngx_pool_t *pool, void *in, size_t len, ngx_str_t *digest); +typedef ngx_int_t (*ngx_http_sticky_misc_hmac_pt)(ngx_pool_t *pool, void *in, size_t len, ngx_str_t *key, ngx_str_t *digest); +typedef ngx_int_t (*ngx_http_sticky_misc_text_pt)(ngx_pool_t *pool, struct sockaddr *in, ngx_str_t *digest); + +ngx_int_t ngx_http_sticky_misc_set_cookie (ngx_http_request_t *r, ngx_str_t *name, ngx_str_t *value, ngx_str_t *domain, ngx_str_t *path, time_t expires); +ngx_int_t ngx_http_sticky_misc_md5(ngx_pool_t *pool, void *in, size_t len, ngx_str_t *digest); +ngx_int_t ngx_http_sticky_misc_sha1(ngx_pool_t *pool, void *in, size_t len, ngx_str_t *digest); +ngx_int_t ngx_http_sticky_misc_hmac_md5(ngx_pool_t *pool, void *in, size_t len, ngx_str_t *key, ngx_str_t *digest); +ngx_int_t ngx_http_sticky_misc_hmac_sha1(ngx_pool_t *pool, void *in, size_t len, ngx_str_t *key, ngx_str_t *digest); + +ngx_int_t ngx_http_sticky_misc_text_raw(ngx_pool_t *pool, struct sockaddr *in, ngx_str_t *digest); +ngx_int_t ngx_http_sticky_misc_text_md5(ngx_pool_t *pool, struct sockaddr *in, ngx_str_t *digest); +ngx_int_t ngx_http_sticky_misc_text_sha1(ngx_pool_t *pool, struct sockaddr *in, ngx_str_t *digest); + +#endif /* _NGX_HTTP_STICKY_MISC_H_INCLUDED_ */ diff --git a/contrib/nginx-sticky-module/ngx_http_sticky_module.c b/contrib/nginx-sticky-module/ngx_http_sticky_module.c new file mode 100644 index 0000000..82d8f5f --- /dev/null +++ b/contrib/nginx-sticky-module/ngx_http_sticky_module.c @@ -0,0 +1,691 @@ + +/* + * Copyright (C) Jerome Loyet + */ + + +#include +#include +#include + +#include "ngx_http_sticky_misc.h" + +/* define a peer */ +typedef struct { + ngx_http_upstream_rr_peer_t *rr_peer; + ngx_str_t digest; +} ngx_http_sticky_peer_t; + +/* the configuration structure */ +typedef struct { + ngx_http_upstream_srv_conf_t uscf; + ngx_str_t cookie_name; + ngx_str_t cookie_domain; + ngx_str_t cookie_path; + time_t cookie_expires; + ngx_str_t hmac_key; + ngx_http_sticky_misc_hash_pt hash; + ngx_http_sticky_misc_hmac_pt hmac; + ngx_http_sticky_misc_text_pt text; + ngx_uint_t no_fallback; + ngx_http_sticky_peer_t *peers; +} ngx_http_sticky_srv_conf_t; + + +/* the custom sticky struct used on each request */ +typedef struct { + /* the round robin data must be first */ + ngx_http_upstream_rr_peer_data_t rrp; + ngx_event_get_peer_pt get_rr_peer; + int selected_peer; + int no_fallback; + ngx_http_sticky_srv_conf_t *sticky_conf; + ngx_http_request_t *request; +} ngx_http_sticky_peer_data_t; + + +static ngx_int_t ngx_http_init_sticky_peer(ngx_http_request_t *r, ngx_http_upstream_srv_conf_t *us); +static ngx_int_t ngx_http_get_sticky_peer(ngx_peer_connection_t *pc, void *data); +static char *ngx_http_sticky_set(ngx_conf_t *cf, ngx_command_t *cmd, void *conf); +static void *ngx_http_sticky_create_conf(ngx_conf_t *cf); + + +static ngx_command_t ngx_http_sticky_commands[] = { + + { ngx_string("sticky"), + NGX_HTTP_UPS_CONF|NGX_CONF_ANY, + ngx_http_sticky_set, + 0, + 0, + NULL }, + + ngx_null_command +}; + + +static ngx_http_module_t ngx_http_sticky_module_ctx = { + NULL, /* preconfiguration */ + NULL, /* postconfiguration */ + + NULL, /* create main configuration */ + NULL, /* init main configuration */ + + ngx_http_sticky_create_conf, /* create server configuration */ + NULL, /* merge server configuration */ + + NULL, /* create location configuration */ + NULL /* merge location configuration */ +}; + + +ngx_module_t ngx_http_sticky_module = { + NGX_MODULE_V1, + &ngx_http_sticky_module_ctx, /* module context */ + ngx_http_sticky_commands, /* module directives */ + NGX_HTTP_MODULE, /* module type */ + NULL, /* init master */ + NULL, /* init module */ + NULL, /* init process */ + NULL, /* init thread */ + NULL, /* exit thread */ + NULL, /* exit process */ + NULL, /* exit master */ + NGX_MODULE_V1_PADDING +}; + + +/* + * function called by the upstream module to init itself + * it's called once per instance + */ +ngx_int_t ngx_http_init_upstream_sticky(ngx_conf_t *cf, ngx_http_upstream_srv_conf_t *us) +{ + ngx_http_upstream_rr_peers_t *rr_peers; + ngx_http_sticky_srv_conf_t *conf; + ngx_uint_t i; + + /* call the rr module on wich the sticky module is based on */ + if (ngx_http_upstream_init_round_robin(cf, us) != NGX_OK) { + return NGX_ERROR; + } + + /* calculate each peer digest once and save */ + rr_peers = us->peer.data; + + /* do nothing there's only one peer */ + if (rr_peers->number <= 1 || rr_peers->single) { + return NGX_OK; + } + + /* tell the upstream module to call ngx_http_init_sticky_peer when it inits peer */ + us->peer.init = ngx_http_init_sticky_peer; + + conf = ngx_http_conf_upstream_srv_conf(us, ngx_http_sticky_module); + + /* if 'index', no need to alloc and generate digest */ + if (!conf->hash && !conf->hmac && !conf->text) { + conf->peers = NULL; + return NGX_OK; + } + + /* create our own upstream indexes */ + conf->peers = ngx_pcalloc(cf->pool, sizeof(ngx_http_sticky_peer_t) * rr_peers->number); + if (conf->peers == NULL) { + return NGX_ERROR; + } + + /* parse each peer and generate digest if necessary */ + for (i = 0; i < rr_peers->number; i++) { + conf->peers[i].rr_peer = &rr_peers->peer[i]; + + if (conf->hmac) { + /* generate hmac */ + conf->hmac(cf->pool, rr_peers->peer[i].sockaddr, rr_peers->peer[i].socklen, &conf->hmac_key, &conf->peers[i].digest); + + } else if (conf->text) { + /* generate text */ + conf->text(cf->pool, rr_peers->peer[i].sockaddr, &conf->peers[i].digest); + + } else { + /* generate hash */ + conf->hash(cf->pool, rr_peers->peer[i].sockaddr, rr_peers->peer[i].socklen, &conf->peers[i].digest); + } + +#if 0 +/* FIXME: is it possible to log to debug level when at configuration stage ? */ + ngx_conf_log_error(NGX_LOG_WARN, cf, 0, "[sticky/ngx_http_init_upstream_sticky] generated digest \"%V\" for upstream at index %d", &conf->peers[i].digest, i); +#endif + + } + + return NGX_OK; +} + +/* + * function called by the upstream module when it inits each peer + * it's called once per request + */ +static ngx_int_t ngx_http_init_sticky_peer(ngx_http_request_t *r, ngx_http_upstream_srv_conf_t *us) +{ + ngx_http_sticky_peer_data_t *iphp; + ngx_str_t route; + ngx_uint_t i; + ngx_int_t n; + + /* alloc custom sticky struct */ + iphp = ngx_palloc(r->pool, sizeof(ngx_http_sticky_peer_data_t)); + if (iphp == NULL) { + return NGX_ERROR; + } + + /* attach it to the request upstream data */ + r->upstream->peer.data = &iphp->rrp; + + /* call the rr module on which the sticky is based on */ + if (ngx_http_upstream_init_round_robin_peer(r, us) != NGX_OK) { + return NGX_ERROR; + } + + /* set the callback to select the next peer to use */ + r->upstream->peer.get = ngx_http_get_sticky_peer; + + /* init the custom sticky struct */ + iphp->get_rr_peer = ngx_http_upstream_get_round_robin_peer; + iphp->selected_peer = -1; + iphp->no_fallback = 0; + iphp->sticky_conf = ngx_http_conf_upstream_srv_conf(us, ngx_http_sticky_module); + iphp->request = r; + + /* check weather a cookie is present or not and save it */ + if (ngx_http_parse_multi_header_lines(&r->headers_in.cookies, &iphp->sticky_conf->cookie_name, &route) != NGX_DECLINED) { + /* a route cookie has been found. Let's give it a try */ + ngx_log_debug(NGX_LOG_DEBUG_HTTP, r->connection->log, 0, "[sticky/init_sticky_peer] got cookie route=%V, let's try to find a matching peer", &route); + + /* hash, hmac or text, just compare digest */ + if (iphp->sticky_conf->hash || iphp->sticky_conf->hmac || iphp->sticky_conf->text) { + + /* check internal struct has been set */ + if (!iphp->sticky_conf->peers) { + /* log a warning, as it will continue without the sticky */ + ngx_log_error(NGX_LOG_WARN, r->connection->log, 0, "[sticky/init_sticky_peer] internal peers struct has not been set"); + return NGX_OK; /* return OK, in order to continue */ + } + + /* search the digest found in the cookie in the peer digest list */ + for (i = 0; i < iphp->rrp.peers->number; i++) { + + /* ensure the both len are equal and > 0 */ + if (iphp->sticky_conf->peers[i].digest.len != route.len || route.len <= 0) { + continue; + } + + if (!ngx_strncmp(iphp->sticky_conf->peers[i].digest.data, route.data, route.len)) { + /* we found a match */ + iphp->selected_peer = i; + ngx_log_debug(NGX_LOG_DEBUG_HTTP, r->connection->log, 0, "[sticky/init_sticky_peer] the route \"%V\" matches peer at index %ui", &route, i); + return NGX_OK; + } + } + + } else { + + /* switch back to index, just convert to integer and ensure it corresponds to a valid peer */ + n = ngx_atoi(route.data, route.len); + if (n == NGX_ERROR) { + ngx_log_error(NGX_LOG_WARN, r->connection->log, 0, "[sticky/init_sticky_peer] unable to convert the route \"%V\" to an integer value", &route); + } else if (n >= 0 && n < (ngx_int_t)iphp->rrp.peers->number) { + /* found one */ + ngx_log_debug(NGX_LOG_DEBUG_HTTP, r->connection->log, 0, "[sticky/init_sticky_peer] the route \"%V\" matches peer at index %i", &route, n); + iphp->selected_peer = n; + return NGX_OK; + } + } + + /* nothing was found, just continue with rr */ + ngx_log_debug(NGX_LOG_DEBUG_HTTP, r->connection->log, 0, "[sticky/init_sticky_peer] the route \"%V\" does not match any peer. Just ignoring it ...", &route); + return NGX_OK; + } + + /* nothing found */ + ngx_log_debug(NGX_LOG_DEBUG_HTTP, r->connection->log, 0, "[sticky/init_sticky_peer] route cookie not found", &route); + return NGX_OK; /* return OK, in order to continue */ +} + +/* + * function called by the upstream module to choose the next peer to use + * called at least one time per request + */ +static ngx_int_t ngx_http_get_sticky_peer(ngx_peer_connection_t *pc, void *data) +{ + ngx_http_sticky_peer_data_t *iphp = data; + ngx_http_sticky_srv_conf_t *conf = iphp->sticky_conf; + ngx_int_t selected_peer = -1; + time_t now = ngx_time(); + uintptr_t m; + ngx_uint_t n, i; + ngx_http_upstream_rr_peer_t *peer = NULL; + + ngx_log_debug(NGX_LOG_DEBUG_HTTP, pc->log, 0, "[sticky/get_sticky_peer] get sticky peer, try: %ui, n_peers: %ui, no_fallback: %ui/%ui", pc->tries, iphp->rrp.peers->number, conf->no_fallback, iphp->no_fallback); + + /* TODO: cached */ + + /* has the sticky module already choosen a peer to connect to and is it a valid peer */ + /* is there more than one peer (otherwise, no choices to make) */ + if (iphp->selected_peer >= 0 && iphp->selected_peer < (ngx_int_t)iphp->rrp.peers->number && !iphp->rrp.peers->single) { + ngx_log_debug(NGX_LOG_DEBUG_HTTP, pc->log, 0, "[sticky/get_sticky_peer] let's try the selected peer (%i)", iphp->selected_peer); + + n = iphp->selected_peer / (8 * sizeof(uintptr_t)); + m = (uintptr_t) 1 << iphp->selected_peer % (8 * sizeof(uintptr_t)); + + /* has the peer not already been tried ? */ + if (!(iphp->rrp.tried[n] & m)) { + peer = &iphp->rrp.peers->peer[iphp->selected_peer]; + + /* if the no_fallback flag is set */ + if (conf->no_fallback) { + + iphp->no_fallback = 1; + + /* if peer is down */ + if (peer->down) { + ngx_log_error(NGX_LOG_NOTICE, pc->log, 0, "[sticky/get_sticky_peer] the selected peer is down and no_fallback is flagged"); + return NGX_BUSY; + } + + /* if it's been ignored for long enought (fail_timeout), reset timeout */ + /* do this check before testing peer->fails ! :) */ + if (now - peer->accessed > peer->fail_timeout) { + peer->fails = 0; + } + + /* if peer is failed */ + if (peer->max_fails > 0 && peer->fails >= peer->max_fails) { + ngx_log_error(NGX_LOG_NOTICE, pc->log, 0, "[sticky/get_sticky_peer] the selected peer is maked as failed and no_fallback is flagged"); + return NGX_BUSY; + } + } + + /* ensure the peer is not marked as down */ + if (!peer->down) { + + /* if it's not failedi, use it */ + if (peer->max_fails == 0 || peer->fails < peer->max_fails) { + selected_peer = (ngx_int_t)n; + + /* if it's been ignored for long enought (fail_timeout), reset timeout and use it */ + } else if (now - peer->accessed > peer->fail_timeout) { + peer->fails = 0; + selected_peer = (ngx_int_t)n; + + /* it's failed or timeout did not expire yet */ + } else { + /* mark the peer as tried */ + iphp->rrp.tried[n] |= m; + } + } + } + } + + /* we have a valid peer, tell the upstream module to use it */ + if (peer && selected_peer >= 0) { + ngx_log_debug(NGX_LOG_DEBUG_HTTP, pc->log, 0, "[sticky/get_sticky_peer] peer found at index %i", selected_peer); + + iphp->rrp.current = iphp->selected_peer; + pc->cached = 0; + pc->connection = NULL; + pc->sockaddr = peer->sockaddr; + pc->socklen = peer->socklen; + pc->name = &peer->name; + + iphp->rrp.tried[n] |= m; + + } else { + ngx_log_debug(NGX_LOG_DEBUG_HTTP, pc->log, 0, "[sticky/get_sticky_peer] no sticky peer selected, switch back to classic rr"); + + if (iphp->no_fallback) { + ngx_log_error(NGX_LOG_NOTICE, pc->log, 0, "[sticky/get_sticky_peer] No fallback in action !"); + return NGX_BUSY; + } + + ngx_int_t ret = iphp->get_rr_peer(pc, &iphp->rrp); + if (ret != NGX_OK) { + ngx_log_debug(NGX_LOG_DEBUG_HTTP, pc->log, 0, "[sticky/get_sticky_peer] ngx_http_upstream_get_round_robin_peer returned %i", ret); + return ret; + } + + /* search for the choosen peer in order to set the cookie */ + for (i = 0; i < iphp->rrp.peers->number; i++) { + + if (iphp->rrp.peers->peer[i].sockaddr == pc->sockaddr && iphp->rrp.peers->peer[i].socklen == pc->socklen) { + if (conf->hash || conf->hmac || conf->text) { + ngx_http_sticky_misc_set_cookie(iphp->request, &conf->cookie_name, &conf->peers[i].digest, &conf->cookie_domain, &conf->cookie_path, conf->cookie_expires); + ngx_log_debug(NGX_LOG_DEBUG_HTTP, pc->log, 0, "[sticky/get_sticky_peer] set cookie \"%V\" value=\"%V\" index=%ui", &conf->cookie_name, &conf->peers[i].digest, i); + } else { + ngx_str_t route; + ngx_uint_t tmp = i; + route.len = 0; + do { + route.len++; + } while (tmp /= 10); + route.data = ngx_pcalloc(iphp->request->pool, sizeof(u_char) * (route.len + 1)); + if (route.data == NULL) { + break; + } + ngx_snprintf(route.data, route.len, "%d", i); + route.len = ngx_strlen(route.data); + ngx_http_sticky_misc_set_cookie(iphp->request, &conf->cookie_name, &route, &conf->cookie_domain, &conf->cookie_path, conf->cookie_expires); + ngx_log_debug(NGX_LOG_DEBUG_HTTP, pc->log, 0, "[sticky/get_sticky_peer] set cookie \"%V\" value=\"%V\" index=%ui", &conf->cookie_name, &tmp, i); + } + break; /* found and hopefully the cookie have been set */ + } + } + } + + /* reset the selection in order to bypass the sticky module when the upstream module will try another peers if necessary */ + iphp->selected_peer = -1; + + return NGX_OK; +} + +/* + * Function called when the sticky command is parsed on the conf file + */ +static char *ngx_http_sticky_set(ngx_conf_t *cf, ngx_command_t *cmd, void *conf) +{ + ngx_http_upstream_srv_conf_t *upstream_conf; + ngx_http_sticky_srv_conf_t *sticky_conf; + ngx_uint_t i; + ngx_str_t tmp; + ngx_str_t name = ngx_string("route"); + ngx_str_t domain = ngx_string(""); + ngx_str_t path = ngx_string(""); + ngx_str_t hmac_key = ngx_string(""); + time_t expires = NGX_CONF_UNSET; + ngx_http_sticky_misc_hash_pt hash = NGX_CONF_UNSET_PTR; + ngx_http_sticky_misc_hmac_pt hmac = NULL; + ngx_http_sticky_misc_text_pt text = NULL; + ngx_uint_t no_fallback = 0; + + /* parse all elements */ + for (i = 1; i < cf->args->nelts; i++) { + ngx_str_t *value = cf->args->elts; + + /* is "name=" is starting the argument ? */ + if ((u_char *)ngx_strstr(value[i].data, "name=") == value[i].data) { + + /* do we have at least on char after "name=" ? */ + if (value[i].len <= sizeof("name=") - 1) { + ngx_conf_log_error(NGX_LOG_EMERG, cf, 0, "a value must be provided to \"name=\""); + return NGX_CONF_ERROR; + } + + /* save what's after "name=" */ + name.len = value[i].len - ngx_strlen("name="); + name.data = (u_char *)(value[i].data + sizeof("name=") - 1); + continue; + } + + /* is "domain=" is starting the argument ? */ + if ((u_char *)ngx_strstr(value[i].data, "domain=") == value[i].data) { + + /* do we have at least on char after "domain=" ? */ + if (value[i].len <= ngx_strlen("domain=")) { + ngx_conf_log_error(NGX_LOG_EMERG, cf, 0, "a value must be provided to \"domain=\""); + return NGX_CONF_ERROR; + } + + /* save what's after "domain=" */ + domain.len = value[i].len - ngx_strlen("domain="); + domain.data = (u_char *)(value[i].data + sizeof("domain=") - 1); + continue; + } + + /* is "path=" is starting the argument ? */ + if ((u_char *)ngx_strstr(value[i].data, "path=") == value[i].data) { + + /* do we have at least on char after "path=" ? */ + if (value[i].len <= ngx_strlen("path=")) { + ngx_conf_log_error(NGX_LOG_EMERG, cf, 0, "a value must be provided to \"path=\""); + return NGX_CONF_ERROR; + } + + /* save what's after "domain=" */ + path.len = value[i].len - ngx_strlen("path="); + path.data = (u_char *)(value[i].data + sizeof("path=") - 1); + continue; + } + + /* is "expires=" is starting the argument ? */ + if ((u_char *)ngx_strstr(value[i].data, "expires=") == value[i].data) { + + /* do we have at least on char after "expires=" ? */ + if (value[i].len <= sizeof("expires=") - 1) { + ngx_conf_log_error(NGX_LOG_EMERG, cf, 0, "a value must be provided to \"expires=\""); + return NGX_CONF_ERROR; + } + + /* extract value */ + tmp.len = value[i].len - ngx_strlen("expires="); + tmp.data = (u_char *)(value[i].data + sizeof("expires=") - 1); + + /* convert to time, save and validate */ + expires = ngx_parse_time(&tmp, 1); + if (expires == NGX_ERROR || expires < 1) { + ngx_conf_log_error(NGX_LOG_EMERG, cf, 0, "invalid value for \"expires=\""); + return NGX_CONF_ERROR; + } + continue; + } + + /* is "text=" is starting the argument ? */ + if ((u_char *)ngx_strstr(value[i].data, "text=") == value[i].data) { + + /* only hash or hmac can be used, not both */ + if (hmac || hash != NGX_CONF_UNSET_PTR) { + ngx_conf_log_error(NGX_LOG_EMERG, cf, 0, "please choose between \"hash=\", \"hmac=\" and \"text\""); + return NGX_CONF_ERROR; + } + + /* do we have at least on char after "name=" ? */ + if (value[i].len <= sizeof("text=") - 1) { + ngx_conf_log_error(NGX_LOG_EMERG, cf, 0, "a value must be provided to \"text=\""); + return NGX_CONF_ERROR; + } + + /* extract value to temp */ + tmp.len = value[i].len - ngx_strlen("text="); + tmp.data = (u_char *)(value[i].data + sizeof("text=") - 1); + + /* is name=raw */ + if (ngx_strncmp(tmp.data, "raw", sizeof("raw") - 1) == 0 ) { + text = ngx_http_sticky_misc_text_raw; + continue; + } + + /* is name=md5 */ + if (ngx_strncmp(tmp.data, "md5", sizeof("md5") - 1) == 0 ) { + text = ngx_http_sticky_misc_text_md5; + continue; + } + + /* is name=sha1 */ + if (ngx_strncmp(tmp.data, "sha1", sizeof("sha1") - 1) == 0 ) { + text = ngx_http_sticky_misc_text_sha1; + continue; + } + + ngx_conf_log_error(NGX_LOG_EMERG, cf, 0, "wrong value for \"text=\": raw, md5 or sha1"); + return NGX_CONF_ERROR; + } + + /* is "hash=" is starting the argument ? */ + if ((u_char *)ngx_strstr(value[i].data, "hash=") == value[i].data) { + + /* only hash or hmac can be used, not both */ + if (hmac || text) { + ngx_conf_log_error(NGX_LOG_EMERG, cf, 0, "please choose between \"hash=\", \"hmac=\" and \"text=\""); + return NGX_CONF_ERROR; + } + + /* do we have at least on char after "hash=" ? */ + if (value[i].len <= sizeof("hash=") - 1) { + ngx_conf_log_error(NGX_LOG_EMERG, cf, 0, "a value must be provided to \"hash=\""); + return NGX_CONF_ERROR; + } + + /* extract value to temp */ + tmp.len = value[i].len - ngx_strlen("hash="); + tmp.data = (u_char *)(value[i].data + sizeof("hash=") - 1); + + /* is hash=index */ + if (ngx_strncmp(tmp.data, "index", sizeof("index") - 1) == 0 ) { + hash = NULL; + continue; + } + + /* is hash=md5 */ + if (ngx_strncmp(tmp.data, "md5", sizeof("md5") - 1) == 0 ) { + hash = ngx_http_sticky_misc_md5; + continue; + } + + /* is hash=sha1 */ + if (ngx_strncmp(tmp.data, "sha1", sizeof("sha1") - 1) == 0 ) { + hash = ngx_http_sticky_misc_sha1; + continue; + } + + ngx_conf_log_error(NGX_LOG_EMERG, cf, 0, "wrong value for \"hash=\": index, md5 or sha1"); + return NGX_CONF_ERROR; + } + + /* is "hmac=" is starting the argument ? */ + if ((u_char *)ngx_strstr(value[i].data, "hmac=") == value[i].data) { + + /* only hash or hmac can be used, not both */ + if (hash != NGX_CONF_UNSET_PTR || text) { + ngx_conf_log_error(NGX_LOG_EMERG, cf, 0, "please choose between \"hash=\", \"hmac=\" and \"text\""); + return NGX_CONF_ERROR; + } + + /* do we have at least on char after "hmac=" ? */ + if (value[i].len <= sizeof("hmac=") - 1) { + ngx_conf_log_error(NGX_LOG_EMERG, cf, 0, "a value must be provided to \"hmac=\""); + return NGX_CONF_ERROR; + } + + /* extract value */ + tmp.len = value[i].len - ngx_strlen("hmac="); + tmp.data = (u_char *)(value[i].data + sizeof("hmac=") - 1); + + /* is hmac=md5 ? */ + if (ngx_strncmp(tmp.data, "md5", sizeof("md5") - 1) == 0 ) { + hmac = ngx_http_sticky_misc_hmac_md5; + continue; + } + + /* is hmac=sha1 ? */ + if (ngx_strncmp(tmp.data, "sha1", sizeof("sha1") - 1) == 0 ) { + hmac = ngx_http_sticky_misc_hmac_sha1; + continue; + } + ngx_conf_log_error(NGX_LOG_EMERG, cf, 0, "wrong value for \"hmac=\": md5 or sha1"); + return NGX_CONF_ERROR; + } + + /* is "hmac_key=" is starting the argument ? */ + if ((u_char *)ngx_strstr(value[i].data, "hmac_key=") == value[i].data) { + + /* do we have at least on char after "hmac_key=" ? */ + if (value[i].len <= ngx_strlen("hmac_key=")) { + ngx_conf_log_error(NGX_LOG_EMERG, cf, 0, "a value must be provided to \"hmac_key=\""); + return NGX_CONF_ERROR; + } + + /* save what's after "hmac_key=" */ + hmac_key.len = value[i].len - ngx_strlen("hmac_key="); + hmac_key.data = (u_char *)(value[i].data + sizeof("hmac_key=") - 1); + continue; + } + + /* is "no_fallback" flag present ? */ + if (ngx_strncmp(value[i].data, "no_fallback", sizeof("no_fallback") - 1) == 0 ) { + no_fallback = 1; + continue; + } + + ngx_conf_log_error(NGX_LOG_EMERG, cf, 0, "invalid arguement (%V)", &value[i]); + return NGX_CONF_ERROR; + } + + /* if has and hmac and name have not been set, default to md5 */ + if (hash == NGX_CONF_UNSET_PTR && hmac == NULL && text == NULL) { + hash = ngx_http_sticky_misc_md5; + } + + /* don't allow meaning less parameters */ + if (hmac_key.len > 0 && hash != NGX_CONF_UNSET_PTR) { + ngx_conf_log_error(NGX_LOG_EMERG, cf, 0, "\"hmac_key=\" is meaningless when \"hmac\" is used. Please remove it."); + return NGX_CONF_ERROR; + } + + /* ensure we have an hmac key if hmac's been set */ + if (hmac_key.len == 0 && hmac != NULL) { + ngx_conf_log_error(NGX_LOG_EMERG, cf, 0, "please specify \"hmac_key=\" when using \"hmac\""); + return NGX_CONF_ERROR; + } + + /* ensure hash is NULL to avoid conflicts later */ + if (hash == NGX_CONF_UNSET_PTR) { + hash = NULL; + } + + /* save the sticky parameters */ + sticky_conf = ngx_http_conf_get_module_srv_conf(cf, ngx_http_sticky_module); + sticky_conf->cookie_name = name; + sticky_conf->cookie_domain = domain; + sticky_conf->cookie_path = path; + sticky_conf->cookie_expires = expires; + sticky_conf->hash = hash; + sticky_conf->hmac = hmac; + sticky_conf->text = text; + sticky_conf->hmac_key = hmac_key; + sticky_conf->no_fallback = no_fallback; + sticky_conf->peers = NULL; /* ensure it's null before running */ + + upstream_conf = ngx_http_conf_get_module_srv_conf(cf, ngx_http_upstream_module); + + /* + * ensure another upstream module has not been already loaded + * peer.init_upstream is set to null and the upstream module use RR if not set + * But this check only works when the other module is declared before sticky + */ + if (upstream_conf->peer.init_upstream) { + ngx_conf_log_error(NGX_LOG_EMERG, cf, 0, "You can't use sticky with another upstream module"); + return NGX_CONF_ERROR; + } + + /* configure the upstream to get back to this module */ + upstream_conf->peer.init_upstream = ngx_http_init_upstream_sticky; + + upstream_conf->flags = NGX_HTTP_UPSTREAM_CREATE + | NGX_HTTP_UPSTREAM_MAX_FAILS + | NGX_HTTP_UPSTREAM_FAIL_TIMEOUT + | NGX_HTTP_UPSTREAM_DOWN + | NGX_HTTP_UPSTREAM_WEIGHT; + + return NGX_CONF_OK; +} + +/* + * alloc stick configuration + */ +static void *ngx_http_sticky_create_conf(ngx_conf_t *cf) +{ + ngx_http_sticky_srv_conf_t *conf = ngx_pcalloc(cf->pool, sizeof(ngx_http_sticky_srv_conf_t)); + if (conf == NULL) { + return NGX_CONF_ERROR; + } + + return conf; +} diff --git a/pkg/after-install.sh b/pkg/after-install.sh new file mode 100644 index 0000000..85051e0 --- /dev/null +++ b/pkg/after-install.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +if [ "$(ls -A /etc/borderpatrol/sites-enabled)" ]; then + echo "/etc/borderpatrol/sites-enabled is not empty; skipping symlinking of default conf file" +else + ln -s /etc/borderpatrol/sites-available/default.conf /etc/borderpatrol/sites-enabled/default.conf || true +fi diff --git a/pkg/borderpatrol.conf b/pkg/borderpatrol.conf new file mode 100644 index 0000000..8efcd07 --- /dev/null +++ b/pkg/borderpatrol.conf @@ -0,0 +1,9 @@ +pid /var/run/borderpatrol.pid; +daemon on; + +events { + worker_connections 40; +} + +include /etc/borderpatrol/conf.d/*.conf; +include /etc/borderpatrol/sites-enabled/*.conf; diff --git a/pkg/borderpatrol.init b/pkg/borderpatrol.init new file mode 100644 index 0000000..007fbcf --- /dev/null +++ b/pkg/borderpatrol.init @@ -0,0 +1,148 @@ +#!/bin/sh +### BEGIN INIT INFO +# Provides: borderpatrol +# Required-Start: $network $remote_fs $local_fs +# Required-Stop: $network $remote_fs $local_fs +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: Stop/start borderpatrol +### END INIT INFO + +# Author: Sergey Budnevitch + +PATH=/sbin:/usr/sbin:/bin:/usr/bin +DESC=borderpatrol +NAME=borderpatrol +CONFFILE=/etc/borderpatrol/borderpatrol.conf +DAEMON=/usr/sbin/borderpatrol +PIDFILE=/var/run/$NAME.pid +SCRIPTNAME=/etc/init.d/$NAME + +[ -x $DAEMON ] || exit 0 + +[ -r /etc/default/$NAME ] && . /etc/default/$NAME + +DAEMON_ARGS="-c $CONFFILE $DAEMON_ARGS" + +. /lib/init/vars.sh + +. /lib/lsb/init-functions + +do_start() +{ + start-stop-daemon --start --quiet --pidfile $PIDFILE --exec $DAEMON -- \ + $DAEMON_ARGS + RETVAL="$?" + return "$RETVAL" +} + +do_stop() +{ + # Return + # 0 if daemon has been stopped + # 1 if daemon was already stopped + # 2 if daemon could not be stopped + # other if a failure occurred + start-stop-daemon --stop --quiet --retry=TERM/30/KILL/5 --pidfile $PIDFILE --name $NAME + RETVAL="$?" + rm -f $PIDFILE + return "$RETVAL" +} + +do_reload() { + # + start-stop-daemon --stop --signal HUP --quiet --pidfile $PIDFILE --name $NAME + RETVAL="$?" + return "$RETVAL" +} + +do_configtest() { + if [ "$#" -ne 0 ]; then + case "$1" in + -q) + FLAG=$1 + ;; + *) + ;; + esac + shift + fi + $DAEMON -t $FLAG -c $CONFFILE + RETVAL="$?" + return $RETVAL +} + +do_upgrade() { + OLDBINPIDFILE=$PIDFILE.oldbin + + do_configtest -q || return 6 + start-stop-daemon --stop --signal USR2 --quiet --pidfile $PIDFILE --name $NAME + RETVAL="$?" + sleep 1 + if [ -f $OLDBINPIDFILE -a -f $PIDFILE ]; then + start-stop-daemon --stop --signal QUIT --quiet --pidfile $OLDBINPIDFILE --name $NAME + RETVAL="$?" + else + echo $"Upgrade failed!" + RETVAL=1 + return $RETVAL + fi +} + +case "$1" in + start) + [ "$VERBOSE" != no ] && log_daemon_msg "Starting $DESC " "$NAME" + do_start + case "$?" in + 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; + 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; + esac + ;; + stop) + [ "$VERBOSE" != no ] && log_daemon_msg "Stopping $DESC" "$NAME" + do_stop + case "$?" in + 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; + 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; + esac + ;; + status) + status_of_proc "$DAEMON" "$NAME" && exit 0 || exit $? + ;; + configtest) + do_configtest + ;; + upgrade) + do_upgrade + ;; + reload|force-reload) + log_daemon_msg "Reloading $DESC" "$NAME" + do_reload + log_end_msg $? + ;; + restart|force-reload) + log_daemon_msg "Restarting $DESC" "$NAME" + do_configtest -q || exit $RETVAL + do_stop + case "$?" in + 0|1) + do_start + case "$?" in + 0) log_end_msg 0 ;; + 1) log_end_msg 1 ;; # Old process is still running + *) log_end_msg 1 ;; # Failed to start + esac + ;; + *) + # Failed to stop + log_end_msg 1 + ;; + esac + ;; + *) + echo "Usage: $SCRIPTNAME {start|stop|status|restart|reload|force-reload|upgrade|configtest}" >&2 + exit 3 + ;; +esac + +exit $RETVAL diff --git a/src/access.lua b/src/access.lua new file mode 100644 index 0000000..fe823cd --- /dev/null +++ b/src/access.lua @@ -0,0 +1,16 @@ +if (ngx.var.auth_token == "") then + -- make a sub request to pull session data + local res = ngx.location.capture("/auth") + + ngx.log(ngx.DEBUG, "==== GET /auth " .. res.status .. " " .. res.body) + + if res.status == ngx.HTTP_OK then + -- set the auth token in the request variables so it can be pulled out + -- and passed as a header then return and allow the request chain to + -- continue. if there's no auth token do the same thing, allowing the + -- upstream service to allow or deny access. + ngx.var.auth_token = res.header['Auth-Token'] + end +else + ngx.log(ngx.DEBUG, "==== skipping GET /auth since auth_token is present: [" .. ngx.var.auth_token .. "]") +end diff --git a/src/authorize.lua b/src/authorize.lua new file mode 100644 index 0000000..68e6290 --- /dev/null +++ b/src/authorize.lua @@ -0,0 +1,91 @@ +local json = require("json") +local sessionid = require("sessionid") + +------------------------------------------- +-- Make the call to the Account Service +------------------------------------------- + +local session_id = ngx.var.cookie_border_session + +-- require session because the only valid scenario for arriving here is via redirect, which should have already set +-- session +if not session_id then + ngx.log(ngx.INFO, "==== access denied: no session_id") + ngx.exit(ngx.HTTP_UNAUTHORIZED) +end +ngx.log(ngx.DEBUG, "==== session_id: " .. session_id) + +if not sessionid.is_valid(session_id) then + ngx.log(ngx.INFO, "==== access denied: session id invalid " .. session_id) + ngx.exit(ngx.HTTP_UNAUTHORIZED) +end + +-- Retrieve original target url and derive service +local res = ngx.location.capture('/session?id=BP_URL_SID_' .. session_id) + +ngx.log(ngx.DEBUG, "==== GET /session?id=BP_URL_SID_" .. session_id .. " " .. res.status) + +-- Get original downstream url they were going to before being redirected +original_url = res.body + +-- get service name from first part of original uri +local service_uri = string.match(original_url, "^/([^/]+)") +local service = nil +if service_uri then + ngx.log(ngx.DEBUG, "==== service uri is: " .. service_uri) + service = service_mappings[service_uri] +end + +-- check service +if not service then + if not service_uri then + ngx.log(ngx.DEBUG, "==== no valid service uri provided") + else + ngx.log(ngx.DEBUG, "==== service not set for service uri: " .. service_uri) + end + ngx.redirect('/account/password') +end + +ngx.req.read_body() +local args = ngx.req.get_post_args() + +-- the account service expects 'e=user@example.com&p=password&t=3&s=servicename' +args['service'] = service +res = ngx.location.capture('/account', { method = ngx.HTTP_POST, body = ngx.encode_args(args) }) + +ngx.log(ngx.DEBUG, "==== POST /account " .. res.status .. " " .. res.body) + +-- assume any 2xx is success +-- On failure, redirect to login +if res.status >= ngx.HTTP_SPECIAL_RESPONSE then + ngx.log(ngx.DEBUG, "==== Authorization against Account Service failed: " .. res.body) + ngx.redirect('/account') +end + +-- parse the response body +local all_tokens_json = res.body +local all_tokens = json.decode(all_tokens_json) + +-- looking for auth tokens +if not all_tokens then + ngx.log(ngx.DEBUG, "==== no tokens found, redirecting to /account") + ngx.redirect('/account') +end + +-- looking for service token +if not all_tokens["service_tokens"][service] then + ngx.log(ngx.DEBUG, "==== parse failure, or service token not found, redirecting to /account") + ngx.redirect('/account') +end + +-- Extract token for specific service +local auth_token = all_tokens["service_tokens"][service] + +-- store all tokens in memcache via internal subrequest +local res = ngx.location.capture('/session?id=BPSID_' .. session_id .. + '&arg_exptime=' .. sessionid.EXPTIME, { body = all_tokens_json, method = ngx.HTTP_PUT }) + +ngx.log(ngx.DEBUG, "==== PUT /session?id=BPSID_" .. session_id .. + '&arg_exptime=' .. sessionid.EXPTIME .. " " .. res.status) + +ngx.redirect(original_url) diff --git a/src/config/nginx.conf.sample b/src/config/nginx.conf.sample new file mode 100644 index 0000000..7f2fdd1 --- /dev/null +++ b/src/config/nginx.conf.sample @@ -0,0 +1,151 @@ +pid /tmp/nginx.pid; +daemon off; + +http { + lua_package_path "../../build/usr/share/borderpatrol/?.lua;../../build/usr/share/lua/5.1/?.lua;;"; + lua_package_cpath "../../build/usr/lib/lua/5.1/?.so;;"; + limit_req_zone $binary_remote_addr zone=auth_zone:100m rate=100r/m; + + error_log logs/error.log debug; + access_log logs/access.log; + + # used to store and retrieve keys from memcached + upstream session_store { + server localhost:11211; + keepalive 32; + } + + # this is an app server protected by border patrol. If it returns a 401 + # when an attempt is made to access a protected resource, borderpatrol redirects + # to the account service login + upstream b { + server localhost:9082; + } + + # this is an app server protected by border patrol. If it returns a 401 + # when an attempt is made to access a protected resource, borderpatrol redirects + # to the account service login + upstream c { + server localhost:9083; + } + + # this is the account service. displays the login screen and also calls the auth service + # to get a master token and a service token + upstream account { + server localhost:9084; + } + + # Nginx Lua has no SSL support for cosockets. This is unfortunate. + # This proxies all requests to use the native NGINX request, though + # it's a little hacky and sort of dirty. + upstream token_server { + server localhost:9081; + } + + # Service mappings, map service urls to service names + init_by_lua 'service_mappings = {b="smb", c="flexd"}'; + + server { + listen 4443 default_server ssl; + root html; + + ssl_prefer_server_ciphers on; + ssl_protocols SSLv3 TLSv1 TLSv1.1 TLSv1.2; + ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-RC4-SHA:ECDHE-RSA-AES128-SHA:AES128-GCM-SHA256:RC4:HIGH:!MD5:!aNULL:!EDH; + ssl_session_cache shared:SSL:16m; + ssl_session_timeout 10m; + ssl_certificate ../ssl/server.crt; + ssl_certificate_key ../ssl/server.key; + + # GET /session?id=foo -> memcache get + # POST /session?id=foo -> memcache add, value is request body + # PUT /session?id=foo -> memcache set, value is request body + location = /session { + internal; + set $memc_key $arg_id; + set $memc_exptime $arg_exptime; + memc_pass session_store; + } + + # DELETE /session_delete?id=foo -> memcache delete + location = /session_delete { + internal; + set $memc_key $arg_id; + memc_pass session_store; + } + + location = /auth { + internal; + content_by_lua_file '../../build/usr/share/borderpatrol/validate.lua'; + } + + location = /serviceauth { + internal; + content_by_lua_file '../../build/usr/share/borderpatrol/service_token.lua'; + } + + location = /authtoken { + internal; + rewrite ^/(.*) /api/auth/public/v1/account_token.json break; + proxy_pass http://token_server; + proxy_set_header Host $host; + } + + location = /mastertoken { + internal; + rewrite ^/(.*) /api/auth/service/v1/account_token.json break; + proxy_pass http://token_server; + proxy_set_header Host $host; + } + + location = / { + limit_req zone=auth_zone burst=25; + content_by_lua_file '../../build/usr/share/borderpatrol/authorize.lua'; + } + + location = /logout { + content_by_lua_file '../../build/usr/share/borderpatrol/logout.lua'; + } + + location ~ /(b|c)* { + set $original_uri $uri; + rewrite ^/(.*) / break; + set $auth_token $http_auth_token; + access_by_lua_file '../../build/usr/share/borderpatrol/access.lua'; + proxy_set_header auth-token $auth_token; + proxy_pass http://$1; + proxy_intercept_errors on; + error_page 401 = @redirect; + } + + location @redirect { + content_by_lua_file '../../build/usr/share/borderpatrol/redirect.lua'; + } + + location = /health { + content_by_lua_file '../../build/usr/share/borderpatrol/health_check.lua'; + } + + location /robots.txt { + alias ../../build/usr/share/borderpatrol/robots.txt; + } + + location / { + set $auth_token $http_auth_token; + access_by_lua_file '../../build/usr/share/borderpatrol/access.lua'; + proxy_set_header auth-token $auth_token; + + # http://hostname/upstream_name/uri -> http://upstream_name/uri + rewrite ^/([^/]+)/?(.*)$ /$2 break; + proxy_pass http://$1; + proxy_redirect off; + proxy_set_header Host $host; + } + } +} + +events { + worker_connections 40; +} + +# vim: ft=conf \ No newline at end of file diff --git a/src/health_check.lua b/src/health_check.lua new file mode 100644 index 0000000..f1066f0 --- /dev/null +++ b/src/health_check.lua @@ -0,0 +1,55 @@ +-- +-- This script serves up an HTML page that displays current health of the +-- BorderPatrol. The only check, currently, is that memcache is reachable. +-- +local errors = {} +local health_check = {} + +-- print out the actual HTML +function health_check.output(errors) + ngx.header.content_type = 'text/html'; + ngx.print([[ + + + Border Patrol Health + + + ]]) + + if #errors > 0 then + ngx.print("

Errors

    ") + for i, v in ipairs(errors) do + ngx.print("
  • " .. v .. "
  • ") + end + ngx.print("
") + else + ngx.print("Everything is ok.") + end + + ngx.print([[ + + + ]]) +end + +local res = ngx.location.capture('/session?id=health_check', { method = ngx.HTTP_POST, body = os.time() }) +if not res.status == ngx.HTTP_OK then + errors[#errors+1] = "memcache add: " .. res.status .. ": " .. res.body +end + +res = ngx.location.capture('/session?id=health_check') +if not res.status == ngx.HTTP_OK then + errors[#errors+1] = "memcache get: " .. res.status .. ": " .. res.body +end + +res = ngx.location.capture('/session?id=health_check', { method = ngx.HTTP_PUT, body = os.time() }) +if not res.status == ngx.HTTP_OK then + errors[#errors+1] = "memcache set: " .. res.status .. ": " .. res.body +end + +res = ngx.location.capture('/session?id=health_check', { method = ngx.HTTP_DELETE }) +if not res.status == ngx.HTTP_OK then + errors[#errors+1] = "memcache delete: " .. res.status .. ": " .. res.body +end + +health_check.output(errors) diff --git a/src/logout.lua b/src/logout.lua new file mode 100644 index 0000000..972a028 --- /dev/null +++ b/src/logout.lua @@ -0,0 +1,37 @@ +------------------------------------------- +-- delete auth token by session id +------------------------------------------- + +local args = ngx.req.get_uri_args() +local destination = args['destination'] + +-- default to reasonable paths. +-- allow only relative paths +if not destination or string.sub(destination,1,1) ~= '/' then destination = '/' end + +local session_id = ngx.var.cookie_border_session + +if session_id then + ngx.log(ngx.DEBUG, "==== session_id: " .. session_id) + + -- expires a session + local res = ngx.location.capture('/session_delete?id=BPSID_' .. session_id, + { method = ngx.HTTP_DELETE }) + + ngx.log(ngx.DEBUG, "DELETE /session_delete?id=BPSID_" .. session_id .. " " .. res.status) + + -- expires the temporary url session + local res = ngx.location.capture('/session_delete?id=BP_URL_SID_' .. session_id, + { method = ngx.HTTP_DELETE }) + + ngx.log(ngx.DEBUG, "DELETE /session_delete?id=BP_URL_SID_" .. session_id .. " " .. res.status) + + -- unset session cookie (expires now() - 1yr) + ngx.header['Set-Cookie'] = 'border_session=' .. + '; path=/; expires=' .. ngx.cookie_time(ngx.time() - 3600 * 24 * 360) + +else + ngx.log(ngx.INFO, "==== session_id not set") +end + +ngx.redirect(destination) \ No newline at end of file diff --git a/src/redirect.lua b/src/redirect.lua new file mode 100644 index 0000000..bc30792 --- /dev/null +++ b/src/redirect.lua @@ -0,0 +1,25 @@ +local sessionid = require("sessionid") + +local original_url = ngx.var.original_uri + +-- Create session key and store the url as value (value will be updated later with master and service tokens +local session_id = sessionid.generate(); + +-- store original url in memcache via internal subrequest +local res = ngx.location.capture('/session?id=BP_URL_SID_' .. session_id .. + '&arg_exptime=' .. sessionid.EXPTIME_TMP, { body = original_url, method = ngx.HTTP_POST }) + +ngx.log(ngx.DEBUG, "==== POST /session?id=BP_URL_SID_" .. session_id .. + '&arg_exptime=' .. sessionid.EXPTIME .. " " .. res.status) + +if res.status == ngx.HTTP_CREATED then + -- set the cookie with the session_id + ngx.header['Set-Cookie'] = 'border_session=' .. session_id .. '; path=/; HttpOnly; Secure;' + -- Redirect to account service login + ngx.redirect('/account') +else + ngx.log(ngx.ERR, "==== an error occurred trying to save session: " .. res.status) + ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) +end + + diff --git a/src/robots.txt b/src/robots.txt new file mode 100644 index 0000000..77470cb --- /dev/null +++ b/src/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / \ No newline at end of file diff --git a/src/service_token.lua b/src/service_token.lua new file mode 100644 index 0000000..26a70bd --- /dev/null +++ b/src/service_token.lua @@ -0,0 +1,49 @@ +local json = require("json") +local sessionid = require("sessionid") + +------------------------------------------- +-- get service token using master token +------------------------------------------- + +local session_id = ngx.var.cookie_border_session + +if not session_id then + ngx.log(ngx.INFO, "==== access denied: no session_id") + ngx.exit(ngx.HTTP_UNAUTHORIZED) +end + +ngx.log(ngx.DEBUG, "==== session_id: " .. session_id) + +if not sessionid.is_valid(session_id) then + ngx.log(ngx.INFO, "==== access denied: session id invalid " .. session_id) + ngx.exit(ngx.HTTP_UNAUTHORIZED) +end + +ngx.req.read_body() +local args = ngx.req.get_post_args() +local master_token = args['mastertoken'] +local service = args['service'] + +if master_token and service then + ngx.log(ngx.DEBUG, "==== master token: " .. master_token ) + local params = {} + params['services'] = service + ngx.req.set_header("Auth-Token", master_token) + + res = ngx.location.capture('/mastertoken', { method = ngx.HTTP_POST, body = ngx.encode_args(params)}) + + ngx.log(ngx.DEBUG, "==== POST /mastertoken " .. res.status .. " " .. res.body) + ngx.req.clear_header("Auth-Token") + + -- assume any 2xx is success + if res.status >= ngx.HTTP_SPECIAL_RESPONSE then + ngx.log(ngx.DEBUG, "==== error getting service token using master token: ") + ngx.exit(ngx.HTTP_BAD_REQUEST) + end +else + ngx.log(ngx.DEBUG, "==== master token missing: ") + ngx.exit(ngx.HTTP_BAD_REQUEST) +end +ngx.say(res.body) +ngx.exit(ngx.HTTP_OK) + diff --git a/src/sessionid.lua b/src/sessionid.lua new file mode 100644 index 0000000..489fac3 --- /dev/null +++ b/src/sessionid.lua @@ -0,0 +1,275 @@ +-- session_id module to issue and validate signed session ids +local crypto = require("crypto") + +local module = {} + +-- record expiration time in memcached +local EXPTIME = 60 * 60 +local EXPTIME_TMP = 60 * 60 * 24 * 7 + +-- defines how often secrets will be rotated +local SECRETS_EXP_INTERVAL = 60 * 60 * 24 + +-- internal session cookie lifetime to prevent unbound sessions +local SESSION_COOKIE_LIFETIME = SECRETS_EXP_INTERVAL * 2 + +-- lease lifetime (short-living) +local SECRETS_LEASE_INTERVAL = 5 + +-- signature length - used for basic validation +local HMAC_SHA1_SIGN_LENGTH = 20 + +-- number of random bytes generated for session ids +local DATA_LENGTH = 16 + +-- secret length - we keep 2 secrets in memory +local KEY_LENGTH = 64 + +-- stores the time when to check whether we need to rotate secrets +local ts_next_refresh = -1 + +-- encode string and make it url-safe +local function encode(str, urlsafe) + + local enc_str = ngx.encode_base64(str) + if urlsafe then + enc_str = string.gsub(enc_str, "+", "-") -- plus -> dash + enc_str = string.gsub(enc_str, "/", "_") -- slash to underscore + enc_str = string.gsub(enc_str, "=", "*") -- equal to star + end + + return enc_str +end + +-- decode string +-- returns empty string if data is not base64 encoded +local function decode(str, urlsafe) + + local dec_str = str + if urlsafe then + dec_str = string.gsub(dec_str, "-", "+") -- plus -> dash + dec_str = string.gsub(dec_str, "_", "/") -- slash to underscore + dec_str = string.gsub(dec_str, "*", "=") -- equal to star + end + + return ngx.decode_base64(dec_str) +end + +-- serializes secret into string +local function serialize_secret(secret) + local str + if secret and type(secret) == "table" then + str = secret.data .. ":" .. secret.ts + end + return str +end + +-- deserializes secret string into a table +local function deserialize_secret(str) + local secret + if str and type(str) == "string" then + local startPos, endPos, data, ts = string.find(str, "(.+):(.+)") + secret = {} + secret.data = data + secret.ts = tonumber(ts) + end + return secret +end + +-- +-- function to save secrets in SHM and memcached +-- secret1 mandatory +-- secret2 optional +-- +local function save_secrets(secret1, secret2) + + local secret1_str = serialize_secret(secret1) + local secret2_str = serialize_secret(secret2) + + -- persist in memcached + local res = ngx.location.capture('/session?id=BPS1', + { body = secret1_str, method = ngx.HTTP_PUT }) + if res.status ~= ngx.HTTP_CREATED then + ngx.log(ngx.WARN, "==== failed to persist secret1 " .. secret1_str .. " " .. res.status) + else + ngx.log(ngx.DEBUG, "==== secret1 persisted successfully " .. secret1_str .. " " .. res.status) + end + + -- secret2 is optional + if secret2_str then + -- persist in memcached + res = ngx.location.capture('/session?id=BPS2', + { body = secret2_str, method = ngx.HTTP_PUT }) + if res.status ~= ngx.HTTP_CREATED then + ngx.log(ngx.WARN, "==== failed to persist secret2 " .. secret2_str .. " " .. res.status) + else + ngx.log(ngx.DEBUG, "==== secret2 persisted successfully " .. secret2_str .. " " .. res.status) + end + end +end + +-- +-- function to retrieve secrets, secret1 is current, secret2 is rotated +-- +local function get_secrets() + + local secret1, secret2 + + local res1, res2 = ngx.location.capture_multi{ + { '/session?id=BPS1', { method = ngx.HTTP_GET } }, + { '/session?id=BPS2', { method = ngx.HTTP_GET } }, + } + + if res1.status >= ngx.HTTP_INTERNAL_SERVER_ERROR then + ngx.log(ngx.ERR, "==== could not fetch secrets - memcached down?") + ngx.exit(res1.status) + end + + if res1.status == ngx.HTTP_OK then + secret1 = deserialize_secret(res1.body) + ts_next_refresh = secret1.ts + SECRETS_EXP_INTERVAL -- update to the time when the cookie expired + elseif res1.status == ngx.HTTP_NOT_FOUND then + ngx.log(ngx.ERR, "==== secret1 not found - did memcached just restart?") + -- trigger key refresh in case memcached got restarted + ts_next_refresh = 0 + else + ngx.log(ngx.ERR, "==== failed to retrieve secret1 " .. res1.status) + end + + if res2.status == ngx.HTTP_OK then + secret2 = deserialize_secret(res2.body) + else + ngx.log(ngx.WARN, "==== failed to retrieve secret2 - that's prob okay in case secrets have not been rotated yet. " .. res2.status) + end + + return secret1, secret2 +end + +-- +-- function to refresh secrets for hmac signing +-- +local function refresh_keys_if_required() + + -- initial check + if (ts_next_refresh == -1) then + local secret1, secret2 = get_secrets() + if secret1 then + ts_next_refresh = secret1.ts + SECRETS_EXP_INTERVAL + else + ts_next_refresh = 0 + end + end + + if (ngx.time() > ts_next_refresh) then + ngx.log(ngx.DEBUG, "==== it's time to refresh keys...") + + local res = ngx.location.capture("/session?id=BP_LEASE", { method = ngx.HTTP_GET }) + if res.status == ngx.HTTP_NOT_FOUND then + + local res = ngx.location.capture("/session?id=BP_LEASE&exptime=" .. SECRETS_LEASE_INTERVAL, + { body = "1", method = ngx.HTTP_POST }) + if res.status == ngx.HTTP_CREATED then + + local secret1, secret2 = get_secrets() + + -- generate new secret + local new_secret = {} + new_secret.data = ngx.encode_base64(crypto.rand.bytes(KEY_LENGTH)) + new_secret.ts = ngx.time(); + + -- persist new secrets + save_secrets(new_secret,secret1) + + res = ngx.location.capture("/session_delete?id=BP_LEASE", { method = ngx.HTTP_GET }) + ngx.log(ngx.DEBUG, "==== lease deleted " .. res.status) + + ts_next_refresh = new_secret.ts + SECRETS_EXP_INTERVAL + + elseif res.status == ngx.HTTP_OK then + ngx.log(ngx.DEBUG, "=== lease to update keys already taken - concurrent update?") + else + ngx.log(ngx.WARN, "==== failed to acquire lease " .. res.status) + end + else + ngx.log(ngx.WARN, "==== refresh keys not needed yet") + end + end +end + +-- +-- generate session_id +-- +local function generate() + + refresh_keys_if_required() + + -- create 128 bit of random bytes + local data = crypto.rand.bytes(DATA_LENGTH) + local ts = ngx.time() + local secret1, secret2 = get_secrets() + + -- we could also use 'crypto' for 'digesting' but benchmark shows it's slightly + -- slower: crypto.hmac.digest("sha1", data, secret1, true) + local signature = ngx.hmac_sha1(secret1.data, data .. ts) + local session_id = encode(data, true) .. ":" .. ts .. ":" .. encode(signature, true) + + return session_id +end + +-- +-- function to validate session_id +-- +local function is_valid(session_id) + + refresh_keys_if_required() + + -- parse session id + local startPos, endPos, data, ts, signature = string.find(session_id, "(.+):(.+):(.+)") + ts = tonumber(ts) + + -- check for the obvious + if not data or not signature or not ts then + return false + end + + -- check whether the cookie expired already + if ngx.time() > ts + SESSION_COOKIE_LIFETIME then + return false + end + + -- decode + data = decode(data, true) + signature = decode(signature, true) + + -- check whether data or signature became nil (happens in case it couldn't be decoded properly) + if not data or #data ~= DATA_LENGTH or not signature or #signature ~= HMAC_SHA1_SIGN_LENGTH then + return false + end + + -- re-compute signature + local secret1, secret2 = get_secrets() + if not secret1 then + -- did memcache just restarted? + return false + else + local computed_signature = ngx.hmac_sha1(secret1.data, data .. ts) + + local match = computed_signature == signature + if not match and secret2 then + -- fallback - check with secret2 + -- TBD: issue fresh session cookie to prevent session loss after secrets + -- get rotated, a bit overkill for now + computed_signature = ngx.hmac_sha1(secret2.data, data .. ts) + match = computed_signature == signature + end + + return match + end +end + +module.generate = generate +module.is_valid = is_valid +module.EXPTIME = EXPTIME +module.EXPTIME_TMP = EXPTIME_TMP + +return module \ No newline at end of file diff --git a/src/ssl/README.md b/src/ssl/README.md new file mode 100644 index 0000000..e1a40a7 --- /dev/null +++ b/src/ssl/README.md @@ -0,0 +1,11 @@ +This folder contains self-signed SSL key and certs which will be used for local testing with your browser: + +Generate new ones via (won't be needed though): + + openssl genrsa -des3 -out server.key 1024 + openssl req -new -key server.key -out server.csr + cp server.key server.key.org + openssl rsa -in server.key.org -out server.key + openssl x509 -req -days 365 -in server.csr -signkey server.key -out server.crt + +Additional details can be found
here. \ No newline at end of file diff --git a/src/ssl/server.crt b/src/ssl/server.crt new file mode 100644 index 0000000..1463838 --- /dev/null +++ b/src/ssl/server.crt @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE----- +MIICTzCCAbgCCQDst2eQl9PzIzANBgkqhkiG9w0BAQUFADBsMQswCQYDVQQGEwJV +UzELMAkGA1UECBMCQ0ExFjAUBgNVBAcTDVNhbiBGcmFuY2lzY28xFjAUBgNVBAoT +DUxvb2tvdXQsIEluYy4xIDAeBgNVBAMTF3NlbGYtc2lnbmVkLmxvb2tvdXQuY29t +MB4XDTEzMDkxNzE3MTIzNFoXDTE0MDkxNzE3MTIzNFowbDELMAkGA1UEBhMCVVMx +CzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1TYW4gRnJhbmNpc2NvMRYwFAYDVQQKEw1M +b29rb3V0LCBJbmMuMSAwHgYDVQQDExdzZWxmLXNpZ25lZC5sb29rb3V0LmNvbTCB +nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAnqcN6ZWG1q3E2HDU5Z1OM9HIAV64 +1XBN7JW/+CKR+GOpuRGqlwSQ4QYmB+TZJnB+p0f6whKnb5Cch33m0oSP//NCy0Oz +/TgP33T5bPu+a7/MUIYjpYLwsiD4fWb1AALbJ+ZxqIinouiJUy79tpWU8SjrTxRO +ubLY3FWHKmpSyQ0CAwEAATANBgkqhkiG9w0BAQUFAAOBgQBDKshY0e8H/nu2UaF4 +TV41QX6g6kPEbEGDGlmyuP5n5gDAD0trdBFZpPCRCnoMGObD9InQGQ/ExVpyvvle +0AcRl0Gan6C/zv9brCTf4Ks0Y/s8u9YwmEA+qB5W020Y01GQ+WfNtlM67X2I2j7G +N0AE28/IALl4c1BUwdHs2dVN7A== +-----END CERTIFICATE----- diff --git a/src/ssl/server.key b/src/ssl/server.key new file mode 100644 index 0000000..830f532 --- /dev/null +++ b/src/ssl/server.key @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICXAIBAAKBgQCepw3plYbWrcTYcNTlnU4z0cgBXrjVcE3slb/4IpH4Y6m5EaqX +BJDhBiYH5NkmcH6nR/rCEqdvkJyHfebShI//80LLQ7P9OA/fdPls+75rv8xQhiOl +gvCyIPh9ZvUAAtsn5nGoiKei6IlTLv22lZTxKOtPFE65stjcVYcqalLJDQIDAQAB +AoGAJNIMjoufca9+oeT95BRwE+K6EmdTamXYD/JpTUNosUcgGs2Y09fBcBgnN2nL +Y/pzyosQDX6a0W+0hFWZ/n25lYXgN9gWK5y9xxE/WGuZCgFEcGrPzvXM/r1zSh1s +N10dmpQJbIPEfgOVDZuVokk2+FUJZUxDLbGghPhcWElJUEECQQDPXGKiV5ZVl2U9 +wm3VfhT2iKvpsod5573PSeUs12PtExxwIpZJcD54RRrgJWUtubTWBlSgF02egkko +PihrDofpAkEAw93UYKDoxW42tLvVMMvxOPsshHh+P4A+XjzKXxrNceXxHF0h+xFB +w+6ywoVGnpp6Om08mw3zhaQfTBj2DaWlhQJAKL1x84tZ0f8ouPWWNrfKzpUTkZqt +21mYhT1zdVfsHgv/LljdRhhzbZXGLfuq4Uz3JoWf4sQxT88xKGLt9fqo4QJANzcS +xsa1t+pw+5Qz7lSfxOtxykpZdLdHXbOPbS4WGnSy+sb6bFeaDYz90b5WgSGVMWFY +A3H0Y4k31XD39DLtLQJBALODq7HHEsKAfZTvYOEVmok7EfMV11Cvt9nrAnHtuFK1 +Nw+eYNPCpckN8qVUzsiUHfWdtAYUuvMzcpEF3M2oVMw= +-----END RSA PRIVATE KEY----- diff --git a/src/validate.lua b/src/validate.lua new file mode 100644 index 0000000..18081a2 --- /dev/null +++ b/src/validate.lua @@ -0,0 +1,83 @@ +local json = require("json") +local sessionid = require("sessionid") + +------------------------------------------- +-- Lookup auth token by session id +------------------------------------------- + +local session_id = ngx.var.cookie_border_session + +if not session_id then + ngx.log(ngx.INFO, "==== access denied: no session_id") + ngx.exit(ngx.HTTP_UNAUTHORIZED) +end + +ngx.log(ngx.DEBUG, "==== session_id: " .. session_id) + +if not sessionid.is_valid(session_id) then + ngx.log(ngx.INFO, "==== access denied: session id invalid " .. session_id) + ngx.exit(ngx.HTTP_UNAUTHORIZED) +end + +local auth_token = nil +local all_tokens_json = nil + +local res = ngx.location.capture('/session?id=BPSID_' .. session_id) +ngx.log(ngx.DEBUG, "GET /session?id=BPSID_" .. session_id .. " " .. + res.status) + +local all_tokens = nil + +if res.status == ngx.HTTP_OK then + all_tokens_json = res.body + all_tokens = json.decode(all_tokens_json, {nothrow = true}) + + if all_tokens then + -- get service name from uri + local service_uri = string.match(ngx.var.request_uri,"^/([^/]+)") + local service = nil + if service_uri then + service = service_mappings[service_uri] + end + + auth_token = all_tokens['service_tokens'][service] + + if not auth_token then + ngx.log(ngx.INFO, "==== token not found in session for service : " .. service_uri) + master_token = all_tokens['auth_service'] + if master_token then + local params = {} + params['mastertoken'] = master_token + params['service'] = service + res = ngx.location.capture('/serviceauth', { method = ngx.HTTP_POST, body = ngx.encode_args(params) }) + + -- TODO Check response status (in this case, don't do anything, treat it as missing token) + local specific_token_json = res.body + auth_token = json.decode(specific_token_json, {nothrow = true})['service_tokens'][service] + if auth_token then + all_tokens['service_tokens'][service] = auth_token + all_tokens_json = json.encode(all_tokens) + end + ngx.log(ngx.INFO, "==== retrieved service token for service: " .. service .. " " .. auth_token) + end + end + -- reset the auth_token TTL to maintain a rolling session window + res = ngx.location.capture('/session?id=BPSID_' .. session_id .. + "&exptime=" .. sessionid.EXPTIME, { body = all_tokens_json, method = ngx.HTTP_PUT }) + if res.status ~= ngx.HTTP_CREATED then + ngx.log(ngx.WARN, "==== failed to refresh session w/ id " .. session_id .. + " " .. res.status) + end + end +end + +if not auth_token then + ngx.log(ngx.INFO, "==== access denied: no auth token for session_id " .. + session_id) + ngx.exit(ngx.HTTP_UNAUTHORIZED) +end + +-- If we made it this far, we're good. Inject the Auth-Token header +ngx.header['Auth-Token'] = auth_token +ngx.log(ngx.INFO, "==== request auth header set") +ngx.exit(ngx.HTTP_OK) diff --git a/t/.gitkeep b/t/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/t/access.t b/t/access.t new file mode 100644 index 0000000..2a32138 --- /dev/null +++ b/t/access.t @@ -0,0 +1,91 @@ +use lib 'lib'; +use Test::Nginx::Socket; + +$ENV{TEST_NGINX_MEMCACHED_PORT} ||= 11211; + +plan tests => $Test::Nginx::Socket::RepeatEach * 2 * blocks(); + +run_tests(); + +__DATA__ + +=== TEST 1: test w/ auth-token present in client request +--- main_config +--- http_config +lua_package_path "./build/usr/share/borderpatrol/?.lua;./build/usr/share/lua/5.1/?.lua;;"; +lua_package_cpath "./build/usr/lib/lua/5.1/?.so;;"; + +upstream b { + server 127.0.0.1:$TEST_NGINX_SERVER_PORT; # self +} + +--- config +location /testpath { + echo_status 200; + echo_duplicate 1 $echo_client_request_headers; + echo 'everything is ok'; + echo_flush; +} +location /auth { + echo_status 200; + echo_flush; +} +location /b/testpath { + set $auth_token $http_auth_token; + access_by_lua_file '../../build/usr/share/borderpatrol/access.lua'; + proxy_set_header auth-token $auth_token; + + # http://hostname/upstream_name/uri -> http://upstream_name/uri + rewrite ^/([^/]+)/?(.*)$ /$2 break; + proxy_pass http://$1; + proxy_redirect off; + proxy_set_header Host $host; +} +--- request +GET /b/testpath +--- more_headers +auth-token: tokentokentokentoken +--- error_code: 200 +--- response_body_like +auth-token: tokentokentoken.+everything is ok$ + +=== TEST 2: test w/o auth-token not present in client request but with valid session +--- main_config +--- http_config +lua_package_path "./build/usr/share/borderpatrol/?.lua;./build/usr/share/lua/5.1/?.lua;;"; +lua_package_cpath "./build/usr/lib/lua/5.1/?.so;;"; +init_by_lua 'service_mappings = {b="smb",s="flexd"}'; +upstream b { + server 127.0.0.1:$TEST_NGINX_SERVER_PORT; # self +} +--- config +location /testpath { + echo_status 200; + echo_duplicate 1 $echo_client_request_headers; + echo 'everything is ok'; + echo_flush; +} +location /auth { + internal; + echo_status 200; + more_set_headers 'Auth-Token: tokentokentokentoken'; + echo_flush; +} +location /b/testpath { + set $auth_token $http_auth_token; + access_by_lua_file '../../build/usr/share/borderpatrol/access.lua'; + proxy_set_header auth-token $auth_token; + + # http://hostname/upstream_name/uri -> http://upstream_name/uri + rewrite ^/([^/]+)/?(.*)$ /$2 break; + proxy_pass http://$1; + proxy_redirect off; + proxy_set_header Host $host; +} +--- request +GET /b/testpath +--- more_headers +Cookie: border_session=this-is-a-session-id # not checked here! +--- error_code: 200 +--- response_body_like +auth-token: tokentokentoken.+everything is ok$ \ No newline at end of file diff --git a/t/authorize.t b/t/authorize.t new file mode 100644 index 0000000..41016b7 --- /dev/null +++ b/t/authorize.t @@ -0,0 +1,271 @@ +use lib 'lib'; +use Test::Nginx::Socket; + +$ENV{TEST_NGINX_MEMCACHED_PORT} ||= 11211; + +repeat_each(1); + +plan tests => repeat_each() * (2 * blocks()) - 1; + +run_tests(); + +__DATA__ + +=== TEST 0: initialize memcached +--- main_config +--- http_config +lua_package_path "./build/usr/share/borderpatrol/?.lua;./build/usr/share/lua/5.1/?.lua;;"; +lua_package_cpath "./build/usr/lib/lua/5.1/?.so;;"; +init_by_lua 'service_mappings = {b="smb", s="flexd"}'; +--- config +location /memc_setup { + internal; + set $memc_cmd $arg_cmd; + set $memc_key $arg_key; + + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; +} +location = /setup { + # clear + echo_subrequest GET '/memc_setup?cmd=flush_all'; + echo_subrequest POST '/memc_setup?key=BP_LEASE' -b '1'; + + echo_subrequest POST '/memc_setup?key=BPS1' -b 'mysecret:1595116800'; +} +--- request +GET /setup +--- more_headers +Content-type: application/x-www-form-urlencoded +--- error_code: 200 +--- response_body_like +OK\r +STORED\r +STORED\r + +=== TEST 1: test successful login +--- main_config +--- http_config +lua_package_path "./build/usr/share/borderpatrol/?.lua;./build/usr/share/lua/5.1/?.lua;;"; +lua_package_cpath "./build/usr/lib/lua/5.1/?.so;;"; +init_by_lua 'service_mappings = {b="smb", s="flexd"}'; +--- config +location /memc_setup { + internal; + set $memc_cmd $arg_cmd; + set $memc_key $arg_key; + + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; +} +location = /setup { + # clear + echo_subrequest GET '/memc_setup?cmd=flush_all'; + echo_subrequest POST '/memc_setup?key=BP_LEASE' -b '1'; + echo_subrequest POST '/memc_setup?key=BPS1' -b 'mysecret:1595116800'; + echo_subrequest POST '/memc_setup?key=BP_URL_SID_MDEyMzQ1Njc4OTAxMjM0NQ**:1595116800:9Wc0CzZKO7Mq5Y2NbTaHrIp/gMg*' -b '/b'; + echo_status 200; +} +location = /session { + internal; + set $memc_key $arg_id; + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; +} +location /authorize { # under test + content_by_lua_file '../../build/usr/share/borderpatrol/authorize.lua'; +} +location /account { + internal; + echo_status 200; + echo '{"auth_service": "tokentokentokentoken", "service_tokens": {"smb": "tokentokentokentoken"}}'; + echo_flush; +} +--- request eval +["GET /setup", "POST /authorize + username=foo&password=bar"] +--- more_headers +Content-type: application/x-www-form-urlencoded +Cookie: border_session=MDEyMzQ1Njc4OTAxMjM0NQ**:1595116800:9Wc0CzZKO7Mq5Y2NbTaHrIp/gMg* +---- response_headers_like +Set-Cookie: border_session=.+:.+; path=/; HttpOnly; Secure;$ +Location: http://localhost(?::\d+)?/b$ +--- error_code eval +[200,302] + +=== TEST 2: test unsuccessful login +--- main_config +--- http_config +lua_package_path "./build/usr/share/borderpatrol/?.lua;./build/usr/share/lua/5.1/?.lua;;"; +lua_package_cpath "./build/usr/lib/lua/5.1/?.so;;"; +init_by_lua 'service_mappings = {b="smb", s="flexd"}'; +--- config +location /memc_setup { + internal; + set $memc_cmd $arg_cmd; + set $memc_key $arg_key; + + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; +} +location = /setup { + # clear + echo_subrequest GET '/memc_setup?cmd=flush_all'; + echo_subrequest POST '/memc_setup?key=BP_LEASE' -b '1'; + echo_subrequest POST '/memc_setup?key=BPS1' -b 'mysecret:1595116800'; + echo_subrequest POST '/memc_setup?key=BP_URL_SID_MDEyMzQ1Njc4OTAxMjM0NQ**:1595116800:9Wc0CzZKO7Mq5Y2NbTaHrIp/gMg*' -b '/b'; + echo_status 200; +} +location = /session { + internal; + set $memc_key $arg_id; + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; +} +location /authorize { # under test + content_by_lua_file '../../build/usr/share/borderpatrol/authorize.lua'; +} +location /account { + internal; + echo_status 403; + echo_flush; +} +--- request eval +["GET /setup", "POST /authorize + username=foo&password=bar"] +--- more_headers +Content-type: application/x-www-form-urlencoded +Cookie: border_session=MDEyMzQ1Njc4OTAxMjM0NQ**:1595116800:9Wc0CzZKO7Mq5Y2NbTaHrIp/gMg* +---- response_headers_like +Set-Cookie: border_session=.+:.+; path=/; HttpOnly; Secure;$ +Location: http://localhost(?::\d+)?/b$ +--- error_code eval +[200,302] + +=== TEST 3: test failed login with memcached down +--- main_config +--- http_config +lua_package_path "./build/usr/share/borderpatrol/?.lua;./build/usr/share/lua/5.1/?.lua;;"; +lua_package_cpath "./build/usr/lib/lua/5.1/?.so;;"; +init_by_lua 'service_mappings = {b="smb", s="flexd"}'; +--- config +location /memc_setup { + internal; + set $memc_cmd $arg_cmd; + set $memc_key $arg_key; + + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; +} +location /session { # memcached + internal; + echo_status 502; # simulate memcached down + echo_flush; +} +location /authorize { # under test + content_by_lua_file '../../build/usr/share/borderpatrol/authorize.lua'; +} +location /account { + internal; + echo_status 200; + echo '{"auth_service": "tokentokentokentoken", "service_tokens": {"smb": "tokentokentokentoken"}}'; + echo_flush; +} +--- request eval +"POST /authorize +username=foo&password=bar" +--- more_headers +Content-type: application/x-www-form-urlencoded +Cookie: border_session=MDEyMzQ1Njc4OTAxMjM0NQ**:1595116800:9Wc0CzZKO7Mq5Y2NbTaHrIp/gMg* +---- response_headers_like +Set-Cookie: border_session=.+:.+; path=/; HttpOnly; Secure;$ +Location: http://localhost(?::\d+)?/b$ +--- error_code: 502 + +=== TEST 4: test failed login with Account Service down +--- main_config +--- http_config +lua_package_path "./build/usr/share/borderpatrol/?.lua;./build/usr/share/lua/5.1/?.lua;;"; +lua_package_cpath "./build/usr/lib/lua/5.1/?.so;;"; +init_by_lua 'service_mappings = {b="smb", s="flexd"}'; +--- config +location /memc_setup { + internal; + set $memc_cmd $arg_cmd; + set $memc_key $arg_key; + + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; +} +location = /setup { + # clear + echo_subrequest GET '/memc_setup?cmd=flush_all'; + echo_subrequest POST '/memc_setup?key=BP_LEASE' -b '1'; + echo_subrequest POST '/memc_setup?key=BPS1' -b 'mysecret:1595116800'; + echo_subrequest POST '/memc_setup?key=BP_URL_SID_MDEyMzQ1Njc4OTAxMjM0NQ**:1595116800:9Wc0CzZKO7Mq5Y2NbTaHrIp/gMg*' -b '/b'; + echo_status 200; +} +location = /session { + internal; + set $memc_key $arg_id; + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; +} +location /authorize { # under test + content_by_lua_file '../../build/usr/share/borderpatrol/authorize.lua'; +} +location /account { + internal; + echo_status 500; + echo_flush; +} +--- request eval +["GET /setup", "POST /authorize + username=foo&password=bar"] +--- more_headers +Content-type: application/x-www-form-urlencoded +Cookie: border_session=MDEyMzQ1Njc4OTAxMjM0NQ**:1595116800:9Wc0CzZKO7Mq5Y2NbTaHrIp/gMg* +---- response_headers_like +Set-Cookie: border_session=.+:.+; path=/; HttpOnly; Secure;$ +Location: http://localhost(?::\d+)?/b$ +--- error_code eval +[200,302] + +=== TEST 5: test invalid service url +--- main_config +--- http_config +lua_package_path "./build/usr/share/borderpatrol/?.lua;./build/usr/share/lua/5.1/?.lua;;"; +lua_package_cpath "./build/usr/lib/lua/5.1/?.so;;"; +init_by_lua 'service_mappings = {b="smb", s="flexd"}'; +--- config +location /memc_setup { + internal; + set $memc_cmd $arg_cmd; + set $memc_key $arg_key; + + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; +} +location = /setup { + # clear + echo_subrequest GET '/memc_setup?cmd=flush_all'; + echo_subrequest POST '/memc_setup?key=BP_LEASE' -b '1'; + echo_subrequest POST '/memc_setup?key=BPS1' -b 'mysecret:1595116800'; + echo_subrequest POST '/memc_setup?key=BP_URL_SID_MDEyMzQ1Njc4OTAxMjM0NQ**:1595116800:9Wc0CzZKO7Mq5Y2NbTaHrIp/gMg*' -b '/x'; + echo_status 200; +} +location = /session { + internal; + set $memc_key $arg_id; + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; +} +location /authorize { # under test + content_by_lua_file '../../build/usr/share/borderpatrol/authorize.lua'; +} +location /account { + internal; + echo_status 403; + echo_flush; +} +--- request eval +["GET /setup", "POST /authorize + username=foo&password=bar"] +--- more_headers +Content-type: application/x-www-form-urlencoded +Cookie: border_session=MDEyMzQ1Njc4OTAxMjM0NQ**:1595116800:9Wc0CzZKO7Mq5Y2NbTaHrIp/gMg* +---- response_headers_like +Set-Cookie: border_session=.+:.+; path=/; HttpOnly; Secure;$ +Location: http://localhost(?::\d+)?/b$ +--- error_code eval +[200,302] diff --git a/t/borderpatrol.god b/t/borderpatrol.god new file mode 100644 index 0000000..936d444 --- /dev/null +++ b/t/borderpatrol.god @@ -0,0 +1,69 @@ +# vim: ft=ruby + +ROOT_PATH = Dir.pwd +TEST_PATH = File.join(ROOT_PATH, 't') +SERVICE_PATH = File.join(TEST_PATH, 'services') +SERVER_PATH = File.join(TEST_PATH, 'servroot') +LOG_PATH = File.join(SERVER_PATH, 'logs') + +GROUP_NAME = 'borderpatrol' + +# Watch the api service +God.watch do |w| + w.name = 'api_service' + w.group = GROUP_NAME + w.dir = SERVICE_PATH + w.start = 'bundle exec shotgun api_service.rb -p 9082' + w.log = File.join(LOG_PATH, 'api_service.out') + w.keepalive +end + +# Watch the 2nd api service +God.watch do |w| + w.name = 'api_service2' + w.group = GROUP_NAME + w.dir = SERVICE_PATH + w.start = 'bundle exec shotgun api_service2.rb -p 9083' + w.log = File.join(LOG_PATH, 'api_service2.out') + w.keepalive +end + +# Watch the account service +God.watch do |w| + w.name = 'account_service' + w.group = GROUP_NAME + w.dir = SERVICE_PATH + w.start = 'bundle exec shotgun account_service.rb -p 9084' + w.log = File.join(LOG_PATH, 'account_service.out') + w.keepalive +end + +# Watch the token server +God.watch do |w| + w.name = 'token_service' + w.group = GROUP_NAME + w.dir = SERVICE_PATH + w.start = 'bundle exec shotgun auth_service.rb -p 9081' + w.log = File.join(LOG_PATH, 'token_service.out') + w.keepalive +end + +God.watch do |w| + w.name = 'memcache' + w.group = GROUP_NAME + w.dir = SERVER_PATH + w.start = 'memcached -vvv' + w.log = File.join(LOG_PATH, 'memcached.out') + w.keepalive +end + +God.watch do |w| + w.name = 'nginx' + w.group = GROUP_NAME + w.dir = SERVER_PATH + w.start = "#{ROOT_PATH}/build/usr/sbin/borderpatrol -g 'error_log #{LOG_PATH}/error.log;' -p #{SERVER_PATH} -c #{ROOT_PATH}/build/etc/borderpatrol/sites-available/borderpatrol.conf.sample" + w.log = File.join(LOG_PATH, 'nginx.out') + w.keepalive +end + + diff --git a/t/health.t b/t/health.t new file mode 100644 index 0000000..438694f --- /dev/null +++ b/t/health.t @@ -0,0 +1,33 @@ +use lib 'lib'; +use Test::Nginx::Socket; + +$ENV{TEST_NGINX_MEMCACHED_PORT} ||= 11211; + +repeat_each(1); + +plan tests => repeat_each() * (1 * blocks()); + +run_tests(); + +__DATA__ + +=== TEST 1: heath controller +--- main_config +--- http_config +lua_package_path "./build/usr/share/borderpatrol/?.lua;./build/usr/share/lua/5.1/?.lua;;"; +lua_package_cpath "./build/usr/lib/lua/5.1/?.so;;"; +--- config + location = /session { + set $memc_key $arg_id; + set $memc_exptime 10; + + memc_cmds_allowed get set add delete; + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; + } + + location /health { + content_by_lua_file '../../build/usr/share/borderpatrol/health_check.lua'; + } +--- request + GET /health +--- error_code: 200 diff --git a/t/logout.t b/t/logout.t new file mode 100644 index 0000000..c6dc23d --- /dev/null +++ b/t/logout.t @@ -0,0 +1,77 @@ +use lib 'lib'; +use Test::Nginx::Socket; + +$ENV{TEST_NGINX_MEMCACHED_PORT} ||= 11211; + +repeat_each(1); + +plan tests => repeat_each() * (2 * blocks()); + +run_tests(); + +__DATA__ + +=== TEST 1: test logout w/o destination and w/o session_id +--- main_config +--- http_config +lua_package_path "./build/usr/share/borderpatrol/?.lua;./build/usr/share/lua/5.1/?.lua;;"; +lua_package_cpath "./build/usr/lib/lua/5.1/?.so;;"; +--- config +location /logout { # under test + content_by_lua_file '../../build/usr/share/borderpatrol/logout.lua'; +} +--- request +GET /logout +--- error_code: 302 +--- response_headers_like: Location: http://localhost(?::\d+)?/$ + +=== TEST 2: test logout w/ relative destination and w/o session_id +--- main_config +--- http_config +lua_package_path "./build/usr/share/borderpatrol/?.lua;./build/usr/share/lua/5.1/?.lua;;"; +lua_package_cpath "./build/usr/lib/lua/5.1/?.so;;"; +--- config +location /logout { # under test + content_by_lua_file '../../build/usr/share/borderpatrol/logout.lua'; +} +--- request +GET /logout?destination=/somepath +--- error_code: 302 +--- response_headers_like: Location: http://localhost(?::\d+)?/somepath$ + +=== TEST 3: test logout w/ absolute destination and w/o session_id +--- main_config +--- http_config +lua_package_path "./build/usr/share/borderpatrol/?.lua;./build/usr/share/lua/5.1/?.lua;;"; +lua_package_cpath "./build/usr/lib/lua/5.1/?.so;;"; +--- config +location /logout { # under test + content_by_lua_file '../../build/usr/share/borderpatrol/logout.lua'; +} +--- request +GET /logout?destination=http://www.evil.org +--- error_code: 302 +--- response_headers_like: Location: http://localhost(?::\d+)?/$ + +=== TEST 4: test logout w/ session_id +--- main_config +--- http_config +lua_package_path "./build/usr/share/borderpatrol/?.lua;./build/usr/share/lua/5.1/?.lua;;"; +lua_package_cpath "./build/usr/lib/lua/5.1/?.so;;"; +--- config +location /logout { # under test + content_by_lua_file '../../build/usr/share/borderpatrol/logout.lua'; +} +location /session_delete { # memcached + internal; + echo_status 400; # simulates successful write into memcached + echo_flush; +} +--- request +GET /logout +--- more_headers +Cookie: border_session=wfxLNdl2BrLN9NVuQ9_wiA**:4lcaas0Onjxsn2D6kDVPTw** +--- error_code: 302 +--- response_headers_like +Set-Cookie: border_session=; path=/; expires=.+GMT$ +--- response_headers_like: Location: http://localhost(?::\d+)?/$ diff --git a/t/redirect.t b/t/redirect.t new file mode 100644 index 0000000..8d235ce --- /dev/null +++ b/t/redirect.t @@ -0,0 +1,105 @@ +use lib 'lib'; +use Test::Nginx::Socket; + +$ENV{TEST_NGINX_MEMCACHED_PORT} ||= 11211; + +repeat_each(1); + +plan tests => repeat_each() * (2 * blocks()) - 1; + +run_tests(); + +__DATA__ + +=== TEST 0: initialize memcached +--- main_config +--- http_config +lua_package_path "./build/usr/share/borderpatrol/?.lua;./build/usr/share/lua/5.1/?.lua;;"; +lua_package_cpath "./build/usr/lib/lua/5.1/?.so;;"; +init_by_lua 'service_mappings = {b="smb", s="flexd"}'; +--- config +location /memc_setup { + internal; + set $memc_cmd $arg_cmd; + set $memc_key $arg_key; + + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; +} +location = /setup { + # clear + echo_subrequest GET '/memc_setup?cmd=flush_all'; + echo_subrequest POST '/memc_setup?key=BP_LEASE' -b '1'; + + echo_subrequest POST '/memc_setup?key=BPS1' -b 'mysecret:1595116800'; +} +--- request +GET /setup +--- more_headers +Content-type: application/x-www-form-urlencoded +--- error_code: 200 +--- response_body_like +OK\r +STORED\r +STORED\r + +=== TEST 1: test successful redirect +--- main_config +--- http_config +lua_package_path "./build/usr/share/borderpatrol/?.lua;./build/usr/share/lua/5.1/?.lua;;"; +lua_package_cpath "./build/usr/lib/lua/5.1/?.so;;"; +init_by_lua 'service_mappings = {b="smb", s="flexd"}'; +--- config +location /memc_setup { + internal; + set $memc_cmd $arg_cmd; + set $memc_key $arg_key; + + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; +} +location = /session { + internal; + set $memc_key $arg_id; + set $memc_value $arg_val; + set $memc_exptime $arg_exptime; + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; +} +location /redirect { + content_by_lua_file '../../build/usr/share/borderpatrol/redirect.lua'; +} + +--- request eval +"POST /redirect" +--- more_headers +Content-type: application/x-www-form-urlencoded +--- error_code: 302 +--- response_headers_like +Location: http://localhost(?::\d+)?/account$ + +=== TEST 2: test memcached down +--- main_config +--- http_config +lua_package_path "./build/usr/share/borderpatrol/?.lua;./build/usr/share/lua/5.1/?.lua;;"; +lua_package_cpath "./build/usr/lib/lua/5.1/?.so;;"; +init_by_lua 'service_mappings = {b="smb", s="flexd"}'; +--- config +location /memc_setup { + internal; + set $memc_cmd $arg_cmd; + set $memc_key $arg_key; + + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; +} +location /session { # memcached + internal; + echo_status 502; # simulate memcached down + echo_flush; +} +location /redirect { + content_by_lua_file '../../build/usr/share/borderpatrol/redirect.lua'; +} +--- request eval +"POST /redirect" +--- more_headers +Content-type: application/x-www-form-urlencoded +--- error_code: 502 + diff --git a/t/service_token.t b/t/service_token.t new file mode 100644 index 0000000..8d2d7fd --- /dev/null +++ b/t/service_token.t @@ -0,0 +1,166 @@ +use lib 'lib'; +use Test::Nginx::Socket; + +$ENV{TEST_NGINX_MEMCACHED_PORT} ||= 11211; + +repeat_each(1); + +plan tests => repeat_each() * (2 * blocks()) -3; + +run_tests(); + +__DATA__ + +=== TEST 0: test getting service token with valid master token and valid service name +--- main_config +--- http_config +lua_package_path "./build/usr/share/borderpatrol/?.lua;./build/usr/share/lua/5.1/?.lua;;"; +lua_package_cpath "./build/usr/lib/lua/5.1/?.so;;"; +--- config +location /memc_setup { + internal; + set $memc_cmd $arg_cmd; + set $memc_key $arg_key; + + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; +} +location = /session { + internal; + set $memc_key $arg_id; + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; +} + +location = /mastertoken { + echo_status 200; + echo '{smb: "tokentokentokentoken"}'; + echo_flush; +} + +location /serviceauth { # under test + content_by_lua_file '../../build/usr/share/borderpatrol/service_token.lua'; +} + +--- request eval +"POST /serviceauth +mastertoken=DEADBEEF&service=smb" +--- more_headers +Cookie: border_session=MDEyMzQ1Njc4OTAxMjM0NQ**:1595116800:9Wc0CzZKO7Mq5Y2NbTaHrIp/gMg* +Content-type: application/x-www-form-urlencoded + +--- response_body_like +{smb: "tokentokentokentoken"} + +--- error_code: 200 + +=== TEST 1: test attempt to get service token with valid master token and invalid service name +--- main_config +--- http_config +lua_package_path "./build/usr/share/borderpatrol/?.lua;./build/usr/share/lua/5.1/?.lua;;"; +lua_package_cpath "./build/usr/lib/lua/5.1/?.so;;"; +--- config +location /memc_setup { + internal; + set $memc_cmd $arg_cmd; + set $memc_key $arg_key; + + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; +} +location = /session { + internal; + set $memc_key $arg_id; + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; +} + +location = /mastertoken { + echo_status 400; + echo_flush; +} + +location /serviceauth { # under test + content_by_lua_file '../../build/usr/share/borderpatrol/service_token.lua'; +} + +--- request eval +"POST /serviceauth +mastertoken=DEADBEEF&service=junkservice" +--- more_headers +Cookie: border_session=MDEyMzQ1Njc4OTAxMjM0NQ**:1595116800:9Wc0CzZKO7Mq5Y2NbTaHrIp/gMg* +Content-type: application/x-www-form-urlencoded + +--- error_code: 400 + +=== TEST 2: test attempt to get service token with nil master token +--- main_config +--- http_config +lua_package_path "./build/usr/share/borderpatrol/?.lua;./build/usr/share/lua/5.1/?.lua;;"; +lua_package_cpath "./build/usr/lib/lua/5.1/?.so;;"; +--- config +location /memc_setup { + internal; + set $memc_cmd $arg_cmd; + set $memc_key $arg_key; + + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; +} +location = /session { + internal; + set $memc_key $arg_id; + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; +} + +location = /mastertoken { + echo_status 400; + echo_flush; +} + +location /serviceauth { # under test + content_by_lua_file '../../build/usr/share/borderpatrol/service_token.lua'; +} + +--- request eval +"POST /serviceauth +service=junkservice" +--- more_headers +Cookie: border_session=MDEyMzQ1Njc4OTAxMjM0NQ**:1595116800:9Wc0CzZKO7Mq5Y2NbTaHrIp/gMg* +Content-type: application/x-www-form-urlencoded + +--- error_code: 400 + +=== TEST 3: test attempt to get service token with valid master token but missing service name +--- main_config +--- http_config +lua_package_path "./build/usr/share/borderpatrol/?.lua;./build/usr/share/lua/5.1/?.lua;;"; +lua_package_cpath "./build/usr/lib/lua/5.1/?.so;;"; +--- config +location /memc_setup { + internal; + set $memc_cmd $arg_cmd; + set $memc_key $arg_key; + + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; +} +location = /session { + internal; + set $memc_key $arg_id; + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; +} + +location = /mastertoken { + echo_status 400; + echo_flush; +} + +location /serviceauth { # under test + content_by_lua_file '../../build/usr/share/borderpatrol/service_token.lua'; +} + +--- request eval +"POST /serviceauth +service=junkservice" +--- more_headers +Cookie: border_session=MDEyMzQ1Njc4OTAxMjM0NQ**:1595116800:9Wc0CzZKO7Mq5Y2NbTaHrIp/gMg* +Content-type: application/x-www-form-urlencoded + +--- error_code: 400 + + diff --git a/t/services/account_service.rb b/t/services/account_service.rb new file mode 100644 index 0000000..e99c7e6 --- /dev/null +++ b/t/services/account_service.rb @@ -0,0 +1,89 @@ +require 'sinatra' +require 'json' +require "net/http" +require "uri" + +KEYMASTER_URI = 'http://localhost:9081/api/auth/service/v1/account_master_token.json' + + +post '/' do + $stderr.write "apiserver #{request.url}\n" + service = request['service'] + username = request['username'] + password = request['password'] + + uri = URI.parse(KEYMASTER_URI) + params = {'s'=> service, 'e' => username, 'p' => password} + response = Net::HTTP.post_form(uri, params) + $stderr.write "keymaster status was: #{response.code} response body was #{response.body}\n" + if response.code == '200' + $stderr.write "account service returning: #{response.body}\n" + content_type :json + response.body + else + $stderr.write "Unable to Authorize user!\n" + halt 401, 'Unable to Authorize user!' + end +end + +get '/' do + $stderr.write "apiserver #{request.url}\n" + haml :login, :content_type => 'text/html' +end + +get '/password' do + $stderr.write "apiserver #{request.url}\n" + haml :password, :content_type => 'text/html' +end + +__END__ + +@@ layout +%html + %head + %title + Account Service + %body{:style => 'text-align: center'} + = yield + +@@ index +%h1 Welcome to the Account Service! +%a{:href => '/logout?destination=/b/'} + logout + +@@ loggedout +%h1 Oops, You are not logged in. +%a{:href => '/b/login'} + login + +@@ login +%h1 + ACCOUNT SERVICE LOGIN + +%form{:action => "/", :method => 'post'} + %label + Username + %input{:name => "username", :type => "text", :value => "user@example.com"} + %br/ + %label + Password + %input{:name => "password", :type => "password", :value => "password"} + %br/ + %input{:name => "service", :type => "hidden", :value => "smb"} + %input{:type => "submit", :name => "login", :value => "login"} + +@@ password +%h1 + THIS IS THE ACCOUNT MANAGEMENT PAGE + +%form{:action => "/", :method => 'post'} + %label + Username + %input{:name => "username", :type => "text", :value => "user@example.com"} + %br/ + %label + Password + %input{:name => "password", :type => "password", :value => "password"} + %br/ + %input{:name => "service", :type => "hidden", :value => "smb"} + %input{:type => "submit", :name => "login", :value => "login"} \ No newline at end of file diff --git a/t/services/api_service.rb b/t/services/api_service.rb new file mode 100644 index 0000000..b35a1d6 --- /dev/null +++ b/t/services/api_service.rb @@ -0,0 +1,48 @@ +require 'sinatra' + +["/", "/first/second"].each do |path| + get path do + token = request.env['HTTP_AUTH_TOKEN'] + $stderr.write "apiserver #{request.url} token = #{token}\n" + + if token != 'LIVEKALESMB' + halt 401, 'Ooops, request not authenticated. Did you login?' + #haml :loggedout, :content_type => 'text/html' + else + haml :index, :content_type => 'text/html' + end + end +end + +get '/login' do + $stderr.write "apiserver #{request.url}\n" + haml :login, :content_type => 'text/html' +end + +get '/unrestricted' do + 'This is an unsecured resource.' +end + +get '/unrestricted/1' do + 'This is an unsecured resource.' +end + +__END__ + +@@ layout +%html + %head + %title + Device Login + %body{:style => 'text-align: center'} + = yield + +@@ index +%h1 Welcome to the First Server! +%a{:href => '/logout?destination=/b/'} + logout + +@@ loggedout +%h1 Oops, You are not logged in. +%a{:href => '/b'} + login \ No newline at end of file diff --git a/t/services/api_service2.rb b/t/services/api_service2.rb new file mode 100644 index 0000000..19c9495 --- /dev/null +++ b/t/services/api_service2.rb @@ -0,0 +1,48 @@ +require 'sinatra' + +["/", "/first/second"].each do |path| + get path do + token = request.env['HTTP_AUTH_TOKEN'] + $stderr.write "apiserver2 #{request.url} token = #{token}\n" + + if token != 'LIVEKALEFLEXD' + halt 401, 'Ooops, request not authenticated. Did you login?' + #haml :loggedout, :content_type => 'text/html' + else + haml :index, :content_type => 'text/html' + end + end +end + +get '/login' do + $stderr.write "apiserver2 #{request.url}\n" + haml :login, :content_type => 'text/html' +end + +get '/unrestricted' do + 'This is an unsecured resource.' +end + +get '/unrestricted/1' do + 'This is an unsecured resource.' +end + +__END__ + +@@ layout +%html + %head + %title + Device Login + %body{:style => 'text-align: center'} + = yield + +@@ index +%h1 Welcome to the Second Server! +%a{:href => '/logout?destination=/c/'} + logout + +@@ loggedout +%h1 Oops, You are not logged in. +%a{:href => '/c'} + login \ No newline at end of file diff --git a/t/services/auth_service.rb b/t/services/auth_service.rb new file mode 100644 index 0000000..c54c6e8 --- /dev/null +++ b/t/services/auth_service.rb @@ -0,0 +1,49 @@ +require 'sinatra' +require 'json' + +REQUIRED_PARAMS = [:e, :p, :s] + +# There are 2 possible scenarios +# Get a service token by itself +# wget -S --post-data "e=user@example.com&p=password&s=smb" http://localhost:9081/api/auth/service/v1/account_master_token.json + +# Get a list of service tokens (using a username and password) +# wget -S --post-data "e=user@example.com&p=password&s=smb,flexd" http://localhost:9081/api/auth/service/v1/account_master_token.json +post '/api/auth/service/v1/account_master_token.json' do + $stderr.write "apiserver #{request.url} params = #{params}\n" + REQUIRED_PARAMS.each do |r| + (status 500 and return) unless params.include?(r.to_s) + end + + if params[:e] == 'user@example.com' && params[:p] == 'password' + resp = build_tokens(params[:s]) + resp['auth_service'] = 'DEADBEEF' + content_type :json + resp.to_json + else + status 401 + end +end + +post '/api/auth/service/v1/account_token.json' do + $stderr.write "authserver #{request.url}\n" + $stderr.write "authserver master token #{request.env['HTTP_AUTH_TOKEN']}\n" + (status 500 and return) unless params.include?('services') + master_token = request.env['HTTP_AUTH_TOKEN'] + (status 401 and return) unless master_token == 'DEADBEEF' + (status 401 and return) unless params[:services] !~ /auth_service/ + $stderr.write "authserver service = #{params.inspect}\n" + resp = build_tokens(params[:services]) + $stderr.write "authserver RETURNING #{resp.inspect}\n" + content_type :json + resp.to_json +end + +def build_tokens(services) + resp = {'service_tokens' => {}} + $stderr.write resp + services.split(',').map do |service_name| + resp['service_tokens'][service_name] = "LIVEKALE#{service_name.upcase}" + end + resp +end diff --git a/t/session.t b/t/session.t new file mode 100644 index 0000000..22f14ae --- /dev/null +++ b/t/session.t @@ -0,0 +1,128 @@ +use lib 'lib'; +use Test::Nginx::Socket; + +$ENV{TEST_NGINX_MEMCACHED_PORT} ||= 11211; + +plan tests => $Test::Nginx::Socket::RepeatEach * 2 * blocks(); + +run_tests(); + +__DATA__ + +=== TEST 1: basic expiry +--- main_config +--- http_config +lua_package_path "./build/usr/share/borderpatrol/?.lua;./build/usr/share/lua/5.1/?.lua;;"; +lua_package_cpath "./build/usr/lib/lua/5.1/?.so;;"; +--- config + location /exptime { + echo 'flush_all'; + echo_location '/memc?cmd=flush_all'; + + echo 'set foo BAR'; + echo_subrequest PUT '/memc?key=foo&exptime=1' -b BAR; + + echo 'get foo - 0 sec'; + echo_location '/memc?key=foo'; + echo; + + echo_blocking_sleep 1.1; + + echo 'get foo - 1.1 sec'; + echo_location '/memc?key=foo'; + } + location /memc { + echo_before_body "status: $echo_response_status"; + echo_before_body "exptime: $memc_exptime"; + + set $memc_cmd $arg_cmd; + set $memc_key $arg_key; + set $memc_exptime $arg_exptime; + + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; + } +--- request + GET /exptime +--- response_body_like +^flush_all +status: 200 +exptime: +OK\r +set foo BAR +status: 201 +exptime: 1 +STORED\r +get foo - 0 sec +status: 200 +exptime: +BAR +get foo - 1\.1 sec +status: 404 +exptime: +.*?404 Not Found.*$ + +=== TEST 2: set and reset exptime +--- config + location /exptime { + echo 'flush_all'; + echo_location '/memc?cmd=flush_all'; + + echo 'set foo BAR'; + echo_subrequest PUT '/memc?key=foo&exptime=1' -b BAR; + + echo 'get foo - 0 sec'; + echo_location '/memc?key=foo'; + echo; + + echo 'set foo BAZ'; + echo_subrequest PUT '/memc?key=foo&exptime=2' -b BAR; + + echo_blocking_sleep 1; + + echo 'get foo - 1 sec'; + echo_location '/memc?key=foo'; + echo; + + echo_blocking_sleep 1.1; + + echo 'get foo - 2 sec'; + echo_location '/memc?key=foo'; + } + location /memc { + echo_before_body "status: $echo_response_status"; + echo_before_body "exptime: $memc_exptime"; + + set $memc_cmd $arg_cmd; + set $memc_key $arg_key; + set $memc_exptime $arg_exptime; + + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; + } +--- request + GET /exptime +--- response_body_like +^flush_all +status: 200 +exptime: +OK\r +set foo BAR +status: 201 +exptime: 1 +STORED\r +get foo - 0 sec +status: 200 +exptime: +BAR +set foo BAZ +status: 201 +exptime: 2 +STORED\r +get foo - 1 sec +status: 200 +exptime: +BAR +get foo - 2 sec +status: 404 +exptime: +.*?404 Not Found.*$ + diff --git a/t/validate.t b/t/validate.t new file mode 100644 index 0000000..8b0a330 --- /dev/null +++ b/t/validate.t @@ -0,0 +1,161 @@ +use lib 'lib'; +use Test::Nginx::Socket; + +$ENV{TEST_NGINX_MEMCACHED_PORT} ||= 11211; + +repeat_each(1); + +plan tests => repeat_each() * (2 * blocks()) - 1; + +run_tests(); + +__DATA__ + + +=== TEST 1: test valid sessionid and valid auth token +--- main_config +--- http_config +lua_package_path "./build/usr/share/borderpatrol/?.lua;./build/usr/share/lua/5.1/?.lua;;"; +lua_package_cpath "./build/usr/lib/lua/5.1/?.so;;"; +init_by_lua 'service_mappings = {auth="smb", s="flexd"}'; +--- config +location /memc_setup { + internal; + set $memc_cmd $arg_cmd; + set $memc_key $arg_key; + + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; +} +location = /setup { + # clear + echo_subrequest GET '/memc_setup?cmd=flush_all'; + echo_subrequest POST '/memc_setup?key=BP_LEASE' -b '1'; + echo_subrequest POST '/memc_setup?key=BPS1' -b 'mysecret:1595116800'; + echo_subrequest POST '/memc_setup?key=BPSID_MDEyMzQ1Njc4OTAxMjM0NQ**:1595116800:9Wc0CzZKO7Mq5Y2NbTaHrIp/gMg*' -b '{"auth_service": "aaa","service_tokens": {"smb": "bbb"}}'; +} +location = /session { + internal; + set $memc_key $arg_id; + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; +} + +location = /validate { + content_by_lua_file '../../build/usr/share/borderpatrol/validate.lua'; +} + +location /auth { # under test + echo_location /setup; + echo_location /validate; + echo $echo_response_status; +} +--- request +GET /auth +--- more_headers +Cookie: border_session=MDEyMzQ1Njc4OTAxMjM0NQ**:1595116800:9Wc0CzZKO7Mq5Y2NbTaHrIp/gMg* +--- response_body_like +OK\r +STORED\r +STORED\r +STORED\r +200 + +=== TEST 2: test valid sessionid and but no token stored +--- main_config +--- http_config +lua_package_path "./build/usr/share/borderpatrol/?.lua;./build/usr/share/lua/5.1/?.lua;;"; +lua_package_cpath "./build/usr/lib/lua/5.1/?.so;;"; +--- config +location /memc_setup { + internal; + set $memc_cmd $arg_cmd; + set $memc_key $arg_key; + + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; +} +location = /setup { + # clear + echo_subrequest GET '/memc_setup?cmd=flush_all'; + echo_subrequest POST '/memc_setup?key=BP_LEASE' -b '1'; + echo_subrequest POST '/memc_setup?key=BPS1' -b 'mysecret:1234567890'; +} +location = /session { + internal; + set $memc_key $arg_id; + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; +} + +location = /validate { + content_by_lua_file '../../build/usr/share/borderpatrol/validate.lua'; +} + +location /auth { # under test + echo_location /setup; + echo_location /validate; + echo $echo_response_status; +} +--- request +GET /auth +--- more_headers +Cookie: border_session=MDEyMzQ1Njc4OTAxMjM0NQ**:Mv+cEjtny9UIrLFYBKFKWQoBvPk* +--- response_body_like +OK\r +STORED\r +STORED\r +.*401 Authorization Required.* + +=== TEST 3: test missing sessionid +--- main_config +--- http_config +lua_package_path "./build/usr/share/borderpatrol/?.lua;./build/usr/share/lua/5.1/?.lua;;"; +lua_package_cpath "./build/usr/lib/lua/5.1/?.so;;"; +--- config +location /auth { # under test + content_by_lua_file '../../build/usr/share/borderpatrol/validate.lua'; +} +--- request +GET /auth +--- error_code: 401 + +=== TEST 4: test valid sessionid and valid auth token +--- main_config +--- http_config +lua_package_path "./build/usr/share/borderpatrol/?.lua;./build/usr/share/lua/5.1/?.lua;;"; +lua_package_cpath "./build/usr/lib/lua/5.1/?.so;;"; +--- config +location /memc_setup { + internal; + set $memc_cmd $arg_cmd; + set $memc_key $arg_key; + + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; +} +location = /setup { + # clear + echo_subrequest GET '/memc_setup?cmd=flush_all'; + echo_subrequest POST '/memc_setup?key=BP_LEASE' -b '1'; + echo_subrequest POST '/memc_setup?key=BPS1' -b 'mysecret:1234567890'; +} +location = /session { + internal; + set $memc_key $arg_id; + memc_pass 127.0.0.1:$TEST_NGINX_MEMCACHED_PORT; +} + +location = /validate { + content_by_lua_file '../../build/usr/share/borderpatrol/validate.lua'; +} + +location /auth { # under test + echo_location /setup; + echo_location /validate; + echo $echo_response_status; +} +--- request +GET /auth +--- more_headers +Cookie: border_session==MTExMTExMTExMTExMTExMQ**:Mv+cEjtny9UIrLFYBKFKWQoBvPk* +--- response_body_like +OK\r +STORED\r +STORED\r +.*401 Authorization Required.*