diff options
author | Yves Fischer <yvesf-git@xapek.org> | 2018-11-26 01:35:11 +0100 |
---|---|---|
committer | Yves Fischer <yvesf-git@xapek.org> | 2018-11-26 01:35:11 +0100 |
commit | 3b89dc69da0f88cf8e2290523fa50656ac2ebb5d (patch) | |
tree | 105313b862ca7d8a123a37c279508081744a90d9 | |
download | nginx-auth-totp-3b89dc69da0f88cf8e2290523fa50656ac2ebb5d.tar.gz nginx-auth-totp-3b89dc69da0f88cf8e2290523fa50656ac2ebb5d.zip |
Proof of concept with totp
-rw-r--r-- | .gitignore | 6 | ||||
-rw-r--r-- | Cargo.toml | 24 | ||||
-rw-r--r-- | LICENSE | 202 | ||||
-rw-r--r-- | README.md | 23 | ||||
-rw-r--r-- | src/auth_handler/handler_info.rs | 30 | ||||
-rw-r--r-- | src/auth_handler/handler_login.rs | 142 | ||||
-rw-r--r-- | src/auth_handler/mod.rs | 170 | ||||
-rw-r--r-- | src/auth_handler/urls.rs | 18 | ||||
-rw-r--r-- | src/cookie_store.rs | 133 | ||||
-rw-r--r-- | src/http_server.rs | 298 | ||||
-rw-r--r-- | src/main.rs | 106 | ||||
-rw-r--r-- | src/router.rs | 162 | ||||
-rw-r--r-- | src/system.rs | 8 | ||||
-rw-r--r-- | src/totp.rs | 30 | ||||
-rw-r--r-- | test/etc/nginx.conf | 35 | ||||
-rwxr-xr-x | test/nginx.sh | 18 | ||||
-rwxr-xr-x | test/oathtool.sh | 6 | ||||
-rw-r--r-- | test/www/index.html | 58 | ||||
-rw-r--r-- | test/www/other_page.html | 57 |
19 files changed, 1526 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e79e5d --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/target/ +/.idea/ +/rust.iml + +# don't care about it now +/Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..76b5cd2 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "nginx_auth_totp" +version = "0.1.0" +authors = ["Yves Fischer <yvesf+git@xapek.org>"] + +[dependencies] +ascii = "0.7" +time = "0.1" +getopts = "0.2" +simple_logger = "0.4" +log = "0.3" +oath = "0.10.*" +evmap = "4.0.*" +horrorshow = "0.6.*" +random = "0.12.*" +tokio = "0.1.*" +tokio-threadpool = "0.1.*" +tokio-executor = "0.1.*" +http = "0.1.*" +bytes = "0.4.*" +httparse = "1.3.*" +thread_local = "0.3.*" +cookie = "0.11.*" +url = "1.7.*"
\ No newline at end of file @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b948b67 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# nginx-auth-totp + +A very simple authentication provider to be used with nginx's `auth_request`. +It uses TOTP (Time based One-Time Passwords) for verification. On success it stores +a cookie which is valid for one day. + +### Compile + +It's written in rust, compile it with `cargo build`. + +### Run + +``` +Usage: nginx_auth_totp [options] + +Options: + -o, --port PORT TCP Port to listen on + -d, --debug Use loglevel Debug instead of Warn +``` + +### Nginx configuration + +Find example in `test/etc/nginx.conf`
\ No newline at end of file diff --git a/src/auth_handler/handler_info.rs b/src/auth_handler/handler_info.rs new file mode 100644 index 0000000..0aeaa09 --- /dev/null +++ b/src/auth_handler/handler_info.rs @@ -0,0 +1,30 @@ +use std::io; + +use tokio::prelude::*; + +use http::{Request, Response, StatusCode}; +use bytes::Bytes; + +use ::ApplicationState; +use super::AuthHandler; + +pub(in super) fn respond<'a>(auth_handler: &AuthHandler, state: &ApplicationState, req: &Request<Bytes>, + path_rest: &'a str) -> Response<String> { + let body = html! { + : horrorshow::helper::doctype::HTML; + html { + head { + title: "Hello world!"; + } + body { + h1(id = "heading") { + : "Hello! This is <html />"; + : "And path rest is: "; + : path_rest; + : "... ok :)"; + } + } + } + }; + Response::builder().body(body.to_string()).unwrap() +}
\ No newline at end of file diff --git a/src/auth_handler/handler_login.rs b/src/auth_handler/handler_login.rs new file mode 100644 index 0000000..83d5214 --- /dev/null +++ b/src/auth_handler/handler_login.rs @@ -0,0 +1,142 @@ +use std::io; +use std::borrow::Cow; + +use tokio::prelude::*; + +use http::{Request, Response, StatusCode, Method}; +use http::header::{SET_COOKIE, COOKIE}; +use url::form_urlencoded; + +use ::ApplicationState; +use ::totp; +use super::*; + + +pub(in super) fn GET<'a>(header_infos: &HeaderExtract, state: &ApplicationState, path_rest: &'a str) + -> Response<String> { + let body = if is_logged_in(&header_infos.cookies, &state.cookie_store) { + format!("{}", html! { + : horrorshow::helper::doctype::HTML; + html { + head { + title: "TOTP Login"; + } + body { + h1(id = "heading") { + : "Currently logged in" + } + } + } + }) + } else { + format!("{}", html! { + : horrorshow::helper::doctype::HTML; + html { + head { + title: "TOTP Login"; + } + body { + h1(id = "heading") { + : "Login" + } + form(method="POST") { + label(for="token") { + : "Enter TOTP token" + } + input(name="token",id="token",type="text"); + input(name="redirect", type="hidden", value=path_rest); + input(name="send",type="submit",value="Submit"); + } + } + } + }) + }; + make_response(StatusCode::OK, format!("{}", body)) +} + +fn test_secrets(secrets: &Vec<&str>, token: &String) -> bool { + secrets.iter() + .any(|secret| { + match totp::verify(secret, token) { + Ok(true) => true, + Ok(false) => false, + Err(e) => { + error!("Error from totp::verify: {}", e); + false + } + } + }) +} + +pub(in super) fn POST<'a>(header_infos: &HeaderExtract, state: &ApplicationState, req: &Request<Bytes>) + -> Response<String> { + let mut token = None; + let mut redirect = None; + for (key, val) in form_urlencoded::parse(req.body()) { + if key == "token" { + token = Some(val.into_owned()) + } else if key == "redirect" { + redirect = Some(val.into_owned()) + } + } + if token.is_none() { + return error_handler_internal("missing argument 'token'".to_string()); + } + let redirect = redirect.unwrap_or(Default::default()); + + if header_infos.totp_secrets.is_empty() { + return error_handler_internal("no secrets configured".to_string()) + } + + let mut ret = Response::builder(); + let body = if test_secrets(&header_infos.totp_secrets, &token.unwrap()) { + let cookie_value = state.cookie_store.create_authenticated_cookie(); + let cookie = CookieBuilder::new(COOKIE_NAME, cookie_value.to_string()) + .http_only(true) + .path("/") + .max_age(state.cookie_max_age) + .finish(); + ret.header(SET_COOKIE, cookie.to_string()); + warn!("Authenticated user with cookie {}", cookie); + format!("{}", html! { + : horrorshow::helper::doctype::HTML; + html { + head { + title: "TOTP Successful"; + meta(http-equiv="refresh", content=format!("3; URL={}", redirect)) + } + body { + h1(id = "heading") { + : "Login succesful" + } + a(href="login") { + : "Try again... redirecting to "; + } + span { + : format!("{}", redirect) + } + } + } + }) + } else { + format!("{}", html! { + : horrorshow::helper::doctype::HTML; + html { + head { + title: "TOTP Login failed"; + meta(http-equiv="refresh", content="1") + } + body { + h1(id = "heading") { + : "Login failed" + } + a(href="login") { + : "Try again... " + } + } + } + }) + }; + + ret.body(body).unwrap() +}
\ No newline at end of file diff --git a/src/auth_handler/mod.rs b/src/auth_handler/mod.rs new file mode 100644 index 0000000..b4e02d1 --- /dev/null +++ b/src/auth_handler/mod.rs @@ -0,0 +1,170 @@ +#![allow(warnings)] + +use std::cell::Cell; +use std::collections::HashMap; +use std::io; +use std::marker::Sync; +use std::str; +use std::str::FromStr; +use std::sync::{Arc, RwLock, Mutex, MutexGuard}; +use std::cell::RefCell; + +use time::{Tm, Duration}; +use http::{Request, Response, StatusCode, Method}; +use tokio::prelude::*; +use horrorshow; +use cookie::{Cookie,CookieBuilder}; +use bytes::Bytes; + +use router; +use cookie_store::CookieStore; +use cookie_store::to_cookie; +use http_server::HttpHandler; + +mod urls; +mod handler_login; +mod handler_info; + +pub(in auth_handler) struct HeaderExtract<'a> { + totp_secrets: Vec<&'a str>, + cookies: Vec<Cookie<'a>>, +} + +pub struct HeaderMissing { + name: &'static str, +} + +static HTTP_HEADER_AUTHORIZATION: &'static str = r"Authorization"; +static HTTP_HEADER_X_ORIGINAL_URL: &'static str = r"X-Original-Url"; +static HTTP_HEADER_WWW_AUTHENTICATE: &'static str = r"WWW-Authenticate"; +static HTTP_HEADER_X_TOTP_SECRET: &'static str = r"X-Totp-Secret"; +static COOKIE_NAME: &'static str = r"totp_cookie"; + +#[derive(Clone)] +pub struct AuthHandler { + routing_table: router::RoutingTable<urls::Route>, +} + +pub(in auth_handler) fn make_response(code: StatusCode, body: String) -> Response<String> { + Response::builder().status(code).body(body).unwrap() +} + +pub(in auth_handler) fn error_handler_internal(body: String) -> Response<String> { + Response::builder().status(StatusCode::INTERNAL_SERVER_ERROR).body(body).unwrap() +} + +impl HttpHandler<super::ApplicationState> for AuthHandler { + fn respond(&self, state: &super::ApplicationState, req: Request<Bytes>) -> Response<String> { + match self.routing_table.match_path(req.uri().path()) { + Ok((urls::Route::Info, rest)) => handler_info::respond(self, state, &req, rest), + Ok((urls::Route::Login, rest)) => self.login(state, &req, rest), + Ok((urls::Route::Logout, rest)) => self.logout(state, &req, rest), + Ok((urls::Route::Check, rest)) => self.check(state, &req, rest), + Err(error) => match error { + router::NoMatchingRoute => + make_response(StatusCode::NOT_FOUND, "Resource not found".to_string()), + } + } + } +} + +pub fn is_logged_in(cookies: &Vec<Cookie>, cookie_store: &CookieStore) -> bool { + for cookie in cookies { + if cookie.name() == COOKIE_NAME { + let cookie_value = to_cookie(cookie.value()); + if cookie_value.is_some() && cookie_store.is_cookie_authenticated(&cookie_value.unwrap()) { + return true; + } + } + } + false +} + + +impl AuthHandler { + pub fn make() -> AuthHandler { + AuthHandler { routing_table: urls::create_routing_table() } + } + + fn login<'a>(&self, state: &super::ApplicationState, req: &Request<Bytes>, path_rest: &'a str, + ) -> Response<String> { + let header_infos = match Self::parse_header_infos(req) { + Ok(infos) => infos, + Err(message) => return error_handler_internal(message), + }; + match *req.method() { + Method::GET => handler_login::GET(&header_infos, state, path_rest), + Method::POST => handler_login::POST(&header_infos, state, req), + _ => error_handler_internal("Wrong method".to_string()), + } + } + + fn logout<'a>(&self, state: &super::ApplicationState, req: &Request<Bytes>, path_rest: &'a str, + ) -> Response<String> { + let header_infos = match Self::parse_header_infos(req) { + Ok(infos) => infos, + Err(message) => return error_handler_internal(message), + }; + + let body = format!("Rest: {}", path_rest); + Response::builder().body(body.to_string()).unwrap() + } + + + fn check<'a>(&self, state: &super::ApplicationState, req: &Request<Bytes>, path_rest: &'a str) -> Response<String> { + let header_infos = match Self::parse_header_infos(req) { + Ok(infos) => infos, + Err(message) => return error_handler_internal(message), + }; + if is_logged_in(&header_infos.cookies, &state.cookie_store) { + make_response(StatusCode::OK, "".to_string()) + } else { + make_response(StatusCode::UNAUTHORIZED, "Cookie expired".to_string()) + } + } + + + fn parse_header_infos(req: &Request<Bytes>) -> Result<HeaderExtract, String> { + let mut totp_secrets = Vec::new(); + for header_value in req.headers().get_all(HTTP_HEADER_X_TOTP_SECRET) { + let value = header_value.to_str().or(Err("Failed to read totp-secret header value"))?; + totp_secrets.push(value); + } + + let mut cookies = Vec::new(); + for header_value in req.headers().get_all(::http::header::COOKIE) { + let value = header_value.to_str().or(Err("Failed to read cookie value"))?; + let cookie = Cookie::parse(value).or(Err("Failed to parse cookie value"))?; + cookies.push(cookie); + } + + Ok(HeaderExtract { totp_secrets, cookies }) + } +} + +#[cfg(test)] +mod test1 { + // use super::*; + use test::Bencher; + // use horrorshow::prelude::*; + use horrorshow::helper::doctype; + + #[bench] + fn bench_1(_: &mut Bencher) { + let _ = format!("{}", html! { +: doctype::HTML; +html { +head { +title: "Hello world!"; +} +body { +// attributes +h1(id = "heading") { +// Insert escaped text +: "Hello! This is <html />" +} +} +} +}); + } +} diff --git a/src/auth_handler/urls.rs b/src/auth_handler/urls.rs new file mode 100644 index 0000000..fa7c76f --- /dev/null +++ b/src/auth_handler/urls.rs @@ -0,0 +1,18 @@ +use ::router; + +#[derive(Clone, Copy)] +pub(in auth_handler) enum Route { + Login, + Logout, + Info, + Check +} + +pub(in auth_handler) fn create_routing_table() -> router::RoutingTable<Route> { + let mut r = router::RoutingTable::new(); + r.insert("/info", Route::Info); + r.insert("/login", Route::Login); + r.insert("/logout", Route::Logout); + r.insert("/check", Route::Check); + r +}
\ No newline at end of file diff --git a/src/cookie_store.rs b/src/cookie_store.rs new file mode 100644 index 0000000..a77bb14 --- /dev/null +++ b/src/cookie_store.rs @@ -0,0 +1,133 @@ +use evmap; +use std::sync::{Arc, Mutex, MutexGuard}; +use evmap::{WriteHandle, ReadHandle}; +use std::time; +use std::str; +use std::hash; + +use random; +use random::Source; + +// cookie is a 64-byte printable-characters-only array +pub struct CookieKey([u8; 64]); + +impl PartialEq for CookieKey { + fn eq(&self, other: &CookieKey) -> bool { + self.0[..] == other.0[..] + } +} + +impl Eq for CookieKey {} + +impl hash::Hash for CookieKey { + fn hash<H: hash::Hasher>(&self, state: &mut H) { + for i in self.0.iter() { + state.write_u8(*i) + } + } +} + +impl Clone for CookieKey { + fn clone(&self) -> CookieKey { + CookieKey(self.0) + } +} + +impl ToString for CookieKey { + fn to_string(&self) -> String { + unsafe { str::from_utf8_unchecked(&self.0).to_string() } + } +} + +pub struct CookieStore { + pub reader: ReadHandle<CookieKey, u64>, + pub writer: Arc<Mutex<WriteHandle<CookieKey, u64>>>, +} + +pub fn to_cookie(data: &str) -> Option<CookieKey> { + if data.len() == 64 { + let mut cookie_key: [u8; 64] = [0; 64]; + cookie_key.copy_from_slice(data.as_bytes()); + Some(CookieKey(cookie_key)) + } else { + None + } +} + +static HEXTABLE: [u8; 16] = [b'0', b'1', b'2', b'3', b'4', b'5', b'6', b'7', b'8', b'9', + b'A', b'B', b'C', b'D', b'E', b'F']; + + +impl Clone for CookieStore { + fn clone(&self) -> Self { + CookieStore { + reader: self.reader.clone(), + writer: self.writer.clone(), + } + } +} + +impl CookieStore { + pub fn new() -> CookieStore { + let (r, w) = evmap::new::<CookieKey, u64>(); + CookieStore { + reader: r, + writer: Arc::new(Mutex::new(w)), + } + } + + pub fn create_authenticated_cookie(&self) -> CookieKey { + let mut r = random::default(); + let mut key = [0; 64]; + for it in key.iter_mut() { + let random: u8 = r.read(); + let value = HEXTABLE[(random & 0x0f) as usize]; + *it = value; + } + + let timeout = time::SystemTime::now() + time::Duration::from_secs(60 * 60 * 24); // 1 day + let timeout = timeout.duration_since(time::SystemTime::UNIX_EPOCH).unwrap().as_secs(); + { + let mut writer = self.write_handle(); + writer.insert(CookieKey(key), timeout); + warn!("Insert: {}", CookieKey(key).to_string()); + writer.refresh(); + } + CookieKey(key) + } + + + fn write_handle(&self) -> MutexGuard<WriteHandle<CookieKey, u64>> { + self.writer.lock().unwrap() + } + + fn now_unix_epoch() -> u64 { + time::SystemTime::now() + .duration_since(time::SystemTime::UNIX_EPOCH).unwrap().as_secs() + } + + /// true -> cookie is valid until time + /// false -> cookie is outdated + pub fn is_cookie_authenticated(&self, key: &CookieKey) -> bool { + let reader = &self.reader; + let value = reader.get_and(key, |v| v[0]); + + warn!("Reading {} -> {:?}", key.to_string(), value); + if value.is_none() { + false + } else if value.unwrap() < Self::now_unix_epoch() { + // outdated, remove from map + let mut writer = self.write_handle(); + writer.empty(key.clone()); + // but no refresh - it's not urgent + false + } else { + true + } + } + + pub fn clean_outdated_cookies(&self) { +// unimplemented!() + } +} + diff --git a/src/http_server.rs b/src/http_server.rs new file mode 100644 index 0000000..826163c --- /dev/null +++ b/src/http_server.rs @@ -0,0 +1,298 @@ +use std::fmt; +use std::io; +use std::net::SocketAddr; +use std::sync::Arc; +use std::boxed::Box; +use bytes::Bytes; + +use tokio; +use tokio::net::TcpListener; +use tokio::prelude::*; +use tokio::codec::{Encoder, Decoder}; +use tokio_threadpool::Builder; +use tokio_executor::enter; +use bytes::BytesMut; +use http::header::HeaderValue; +use http::{Request, Response}; +use thread_local::ThreadLocal; + +use system; + +pub trait HttpHandler<T> { + fn respond(&self, state: &T, req: Request<Bytes>) -> Response<String>; +} + +pub fn serve< + T: 'static + Send + Clone + HttpHandler<X>, + X: Send + Clone + 'static +>(addr: SocketAddr, state: X, handler: T) { + let listener = TcpListener::bind(&addr).expect("failed to bind"); + info!("Listening on: {}", addr); + let tl_handler: Arc<ThreadLocal<T>> = Arc::new(ThreadLocal::new()); + let tl_state: Arc<ThreadLocal<X>>= Arc::new(ThreadLocal::new()); + + let program = + listener.incoming() + .map_err(|e| error!("failed to accept socket; error = {:?}", e)) + .for_each(move |socket| { + let peer_addr = match socket.peer_addr() { + Ok(addr) => format!("{}", addr), + Err(_) => "<error>".to_string(), + }; + + let (tx, rx) = + HttpFrame.framed(socket).split(); + + let tl_handler = tl_handler.clone(); + let handler = handler.clone(); + + let tl_state = tl_state.clone(); + let state = state.clone(); + + let rx_task = rx.and_then(move |req| { + let state = state.clone(); + let state = tl_state.get_or(||{ + debug!("Clone state"); + Box::new(state.clone()) + }); + let handler = tl_handler.get_or(|| { + debug!("Clone handler"); + Box::new(handler.clone()) + }); + info!("{:?} {} {} {:?}", peer_addr, req.method(), req.uri(), req.version()); + let response = handler.respond(&state, req); + Box::new(future::ok(response)) + }); + let tx_task = tx.send_all(rx_task) + .then(|res| { + if let Err(e) = res { + error!("failed to process connection; error = {:?}", e); + } + Ok(()) + }); + + // Spawn the task that handles the connection. + tokio::spawn(tx_task); + Ok(()) + }); + + + let mut builder = Builder::new(); + let runtime = builder + .name_prefix("httpd-") + .after_start(|| { + debug!("Start new worker"); + system::initialize_rng_from_time(); + }) + .build(); + runtime.spawn(program); + enter().expect("nested tokio::run") + .block_on(runtime.shutdown_on_idle()) + .unwrap(); +} + +/// +/// The following code is mostly copied from: +/// https://github.com/tokio-rs/tokio/blob/master/examples/tinyhttp.rs +///------------------------------------------------------------------------------------------------- +struct HttpFrame; + +/// Implementation of encoding an HTTP response into a `BytesMut`, basically +/// just writing out an HTTP/1.1 response. +impl Encoder for HttpFrame { + type Item = Response<String>; + type Error = io::Error; + + fn encode(&mut self, item: Response<String>, dst: &mut BytesMut) -> io::Result<()> { + use std::fmt::Write; + + write!(BytesWrite(dst), "\ + HTTP/1.1 {}\r\n\ + Server: nginx-auth-totp\r\n\ + Content-Length: {}\r\n\ + Date: {}\r\n\ + ", item.status(), item.body().len(), date::now()).unwrap(); + + for (k, v) in item.headers() { + dst.extend_from_slice(k.as_str().as_bytes()); + dst.extend_from_slice(b": "); + dst.extend_from_slice(v.as_bytes()); + dst.extend_from_slice(b"\r\n"); + } + + dst.extend_from_slice(b"\r\n"); + dst.extend_from_slice(item.body().as_bytes()); + + return Ok(()); + + // Right now `write!` on `Vec<u8>` goes through io::Write and is not + // super speedy, so inline a less-crufty implementation here which + // doesn't go through io::Error. + struct BytesWrite<'a>(&'a mut BytesMut); + + impl<'a> fmt::Write for BytesWrite<'a> { + fn write_str(&mut self, s: &str) -> fmt::Result { + self.0.extend_from_slice(s.as_bytes()); + Ok(()) + } + + fn write_fmt(&mut self, args: fmt::Arguments) -> fmt::Result { + fmt::write(self, args) + } + } + } +} + +/// Implementation of decoding an HTTP request from the bytes we've read so far. +/// This leverages the `httparse` crate to do the actual parsing and then we use +/// that information to construct an instance of a `http::Request` object, +/// trying to avoid allocations where possible. +impl Decoder for HttpFrame { + type Item = Request<Bytes>; + type Error = io::Error; + + fn decode(&mut self, src: &mut BytesMut) -> io::Result<Option<Request<Bytes>>> { + // TODO: we should grow this headers array if parsing fails and asks + // for more headers + let mut headers = [None; 16]; + let (method, path, version, amt) = { + let mut parsed_headers = [httparse::EMPTY_HEADER; 16]; + let mut r = httparse::Request::new(&mut parsed_headers); + let status = r.parse(src).map_err(|e| { + let msg = format!("failed to parse http request: {:?}", e); + io::Error::new(io::ErrorKind::Other, msg) + })?; + + let amt = match status { + httparse::Status::Complete(amt) => amt, + httparse::Status::Partial => return Ok(None), + }; + + let toslice = |a: &[u8]| { + let start = a.as_ptr() as usize - src.as_ptr() as usize; + assert!(start < src.len()); + (start, start + a.len()) + }; + + for (i, header) in r.headers.iter().enumerate() { + let k = toslice(header.name.as_bytes()); + let v = toslice(header.value); + headers[i] = Some((k, v)); + } + + (toslice(r.method.unwrap().as_bytes()), + toslice(r.path.unwrap().as_bytes()), + r.version.unwrap(), + amt) + }; + if version != 1 && version != 0 { // TODO + error!("Version: {}", version); + return Err(io::Error::new(io::ErrorKind::Other, "only HTTP/1.1 accepted")); + } + let data = src.split_to(amt).freeze(); + let mut req_builder = Request::builder(); + req_builder.method(&data[method.0..method.1]); + req_builder.uri(data.slice(path.0, path.1)); + req_builder.version(http::Version::HTTP_11); + for header in headers.iter() { + let (k, v) = match *header { + Some((ref k, ref v)) => (k, v), + None => break, + }; + let value = unsafe { + HeaderValue::from_shared_unchecked(data.slice(v.0, v.1)) + }; + req_builder.header(&data[k.0..k.1], value); + } + + + let request_body = src.split_off(0).freeze(); + let req = req_builder.body(request_body).map_err(|e| { + io::Error::new(io::ErrorKind::Other, e) + })?; + Ok(Some(req)) + } +} + +mod date { + use std::cell::RefCell; + use std::fmt::{self, Write}; + use std::str; + + use time::{self, Duration}; + + pub struct Now(()); + + /// Returns a struct, which when formatted, renders an appropriate `Date` + /// header value. + pub fn now() -> Now { + Now(()) + } + + // Gee Alex, doesn't this seem like premature optimization. Well you see + // there Billy, you're absolutely correct! If your server is *bottlenecked* + // on rendering the `Date` header, well then boy do I have news for you, you + // don't need this optimization. + // + // In all seriousness, though, a simple "hello world" benchmark which just + // sends back literally "hello world" with standard headers actually is + // bottlenecked on rendering a date into a byte buffer. Since it was at the + // top of a profile, and this was done for some competitive benchmarks, this + // module was written. + // + // Just to be clear, though, I was not intending on doing this because it + // really does seem kinda absurd, but it was done by someone else [1], so I + // blame them! :) + // + // [1]: https://github.com/rapidoid/rapidoid/blob/f1c55c0555007e986b5d069fe1086e6d09933f7b/rapidoid-commons/src/main/java/org/rapidoid/commons/Dates.java#L48-L66 + + struct LastRenderedNow { + bytes: [u8; 128], + amt: usize, + next_update: time::Timespec, + } + + thread_local!(static LAST: RefCell<LastRenderedNow> = RefCell::new(LastRenderedNow { + bytes: [0; 128], + amt: 0, + next_update: time::Timespec::new(0, 0), + })); + + impl fmt::Display for Now { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + LAST.with(|cache| { + let mut cache = cache.borrow_mut(); + let now = time::get_time(); + if now >= cache.next_update { + cache.update(now); + } + f.write_str(cache.buffer()) + }) + } + } + + impl LastRenderedNow { + fn buffer(&self) -> &str { + str::from_utf8(&self.bytes[..self.amt]).unwrap() + } + + fn update(&mut self, now: time::Timespec) { + self.amt = 0; + write!(LocalBuffer(self), "{}", time::at(now).rfc822()).unwrap(); + self.next_update = now + Duration::seconds(1); + self.next_update.nsec = 0; + } + } + + struct LocalBuffer<'a>(&'a mut LastRenderedNow); + + impl<'a> fmt::Write for LocalBuffer<'a> { + fn write_str(&mut self, s: &str) -> fmt::Result { + let start = self.0.amt; + let end = start + s.len(); + self.0.bytes[start..end].copy_from_slice(s.as_bytes()); + self.0.amt += s.len(); + Ok(()) + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..bb3c57e --- /dev/null +++ b/src/main.rs @@ -0,0 +1,106 @@ +#![feature(test)] +#![feature(convert_id)] +#![feature(proc_macro_hygiene)] +#![feature(try_from)] +#![feature(duration_as_u128)] +#![feature(libc)] + +use std::env; +use std::sync::Arc; +use std::thread; +use std::sync::atomic; +use std::net::SocketAddr; + +extern crate ascii; +extern crate getopts; +#[macro_use] +extern crate log; +extern crate tokio; +extern crate tokio_threadpool; +extern crate tokio_executor; +extern crate time; +extern crate simple_logger; +extern crate oath; +extern crate evmap; +extern crate test; +#[macro_use] +extern crate horrorshow; +extern crate random; +extern crate http; +extern crate httparse; +extern crate bytes; +extern crate thread_local; +extern crate cookie; +extern crate url; + +use getopts::Options; +use log::LogLevel::{Debug, Warn}; +use time::Duration; + +mod auth_handler; +mod cookie_store; +mod http_server; +mod router; +mod system; +mod totp; + +extern crate libc; + +use cookie_store::CookieStore; + +#[derive(Clone)] +pub struct ApplicationState { + cookie_store: CookieStore, + cookie_max_age: Duration, +} + +fn print_usage(program: &str, opts: &Options) { + let brief = format!("Usage: {} [options]", program); + print!("{}", opts.usage(&brief)); +} + +fn main() { + let args: Vec<String> = env::args().collect(); + let program = args[0].clone(); + let mut opts = Options::new(); + opts.optopt("l", "port", "Listen address", "LISTEN-ADDR"); + opts.optflag("d", "debug", "Use loglevel Debug instead of Warn"); + opts.optflag("h", "help", "print this help menu"); + let matches = opts.parse(&args[1..]).unwrap_or_else(|f| panic!(f.to_string())); + + if matches.opt_present("h") { + print_usage(&program, &opts); + return; + } + + simple_logger::init_with_level(if matches.opt_present("d") { Debug } else { Warn }) + .unwrap_or_else(|_| panic!("Failed to initialize logger")); + + + let addr = matches.opt_str("l").unwrap_or_else(||"127.0.0.1:8080".to_string()); + let addr = addr.parse::<SocketAddr>() + .unwrap_or_else(|_| panic!("Failed to parse LISTEN-ADDRESS")); + + + // concurrent eventual consistent hashmap with <cookie-id, timeout> + let state = ApplicationState { cookie_store: CookieStore::new(), cookie_max_age: Duration::days(1) }; + + let server_shutdown_condvar = Arc::new(atomic::AtomicBool::new(false)); + + let cookie_clean_thread_condvar = server_shutdown_condvar.clone(); + let cookie_clean_state = state.clone(); + let cookie_clean_thread = thread::spawn(move || { + while !cookie_clean_thread_condvar.load(atomic::Ordering::Relaxed) { + thread::sleep(std::time::Duration::from_secs(60)); + debug!("Clean cookie cache"); + cookie_clean_state.cookie_store.clean_outdated_cookies(); + } + }); + + let auth_handler = auth_handler::AuthHandler::make(); + http_server::serve(addr, state, auth_handler); + + server_shutdown_condvar.store(true, atomic::Ordering::Relaxed); + debug!("Waiting for cleanup thread to shutdown"); + cookie_clean_thread.join().unwrap(); +} diff --git a/src/router.rs b/src/router.rs new file mode 100644 index 0000000..18ed429 --- /dev/null +++ b/src/router.rs @@ -0,0 +1,162 @@ +use std::collections::VecDeque; + +#[derive(Clone, Copy)] +enum TablePointer<R> where R: Clone + Copy { + Link(usize), + Route(R), + RouteWithLink(usize, R), + NotFound, +} + +pub struct NoMatchingRoute; + +pub type RouteMatch<'a, R> = Result<(R, &'a str), NoMatchingRoute>; + +#[derive(Clone)] +pub struct RoutingTable<R> where R: Copy { + tables: Vec<[TablePointer<R>; 128]>, +} + +impl<R> RoutingTable<R> where R: Copy { + pub fn new() -> RoutingTable<R> { + let zero_table = [TablePointer::NotFound; 128]; + RoutingTable { + tables: vec![zero_table], + } + } + + pub fn insert(&mut self, path: &str, route: R) { + assert!(!path.is_empty()); + let mut index: usize = 0; + let p: Vec<usize> = path.as_bytes().iter().map(|i| usize::from(*i)).collect(); + let mut path_queue = VecDeque::from(p); + + while path_queue.len() > 1 { + let ch = path_queue.pop_front().unwrap() as usize % 128; + match self.tables[index][ch] { + TablePointer::NotFound => { + self.tables.push([TablePointer::NotFound; 128]); + let i_other_table = self.tables.len() - 1; + self.tables[index][ch] = TablePointer::Link(i_other_table); + path_queue.push_front(ch); + } + TablePointer::Link(i_other_table) => { + index = i_other_table as usize; + } + TablePointer::RouteWithLink(i_other_table, _) => { + index = i_other_table as usize; + path_queue.push_front(ch); + } + TablePointer::Route(route) => { + self.tables.push([TablePointer::NotFound; 128]); + let i_other_table = self.tables.len() - 1; + self.tables[index][ch] = TablePointer::RouteWithLink(i_other_table, route); + path_queue.push_front(ch); + } + } + } + // last character element in path + let ch = path_queue.pop_front().unwrap() % 128; + match self.tables[index][ch] { + TablePointer::NotFound => { + // slot is empty, just place the Route + self.tables[index][ch] = TablePointer::Route(route) + } + TablePointer::Link(i) => { + // slot is filled with link to longer path. Replace by RouteWithLink + self.tables[index][ch] = TablePointer::RouteWithLink(i, route); + } + _ => { + panic!("Not expected here, maybe duplicate") + } + } + } + + pub fn match_path<'a>(&self, path: &'a str) -> RouteMatch<'a, R> { + let mut table: &[TablePointer<R>; 128] = &self.tables[0]; + let path_bytes = path.as_bytes(); + let path_max_i = path_bytes.len() - 1; + + let mut i = 0; + while i <= path_max_i { + let ch = path_bytes[i] as usize % 128; + match table[ch] { + TablePointer::NotFound => { + return Err(NoMatchingRoute); + } + TablePointer::Link(i_other_table) => { + table = &self.tables[i_other_table]; + i += 1; + } + TablePointer::RouteWithLink(i_other_table, route) => { + if i == path_max_i { + return Ok((route, Default::default())); + } else { + table = &self.tables[i_other_table]; + } + } + TablePointer::Route(route) => { + return Ok((route, path.get(i + 1..=path_max_i).unwrap())); + } + } + } + Err(NoMatchingRoute) + } +} + +#[cfg(test)] +mod test1 { + use super::*; + use test::Bencher; + + #[derive(Clone, Copy)] + enum Route { + Login, + Logout, + Info, + Check + } + + #[bench] + fn bench_1(b: &mut Bencher) { + let mut r = RoutingTable::new(); + r.insert("/login", Route::Login); + r.insert("/info", Route::Info); + r.insert("/logout", Route::Logout); + r.insert("/logout2", Route::Info); + r.insert("/check", Route::Check); + + match r.tables[0][47] { + TablePointer::Link(n) => assert!(n == 1), + _ => panic!("Wrong"), + } + + b.iter(|| { + match r.match_path("/login") { + RouteMatch::Match(Route::Login, rest) => assert_eq!(rest, ""), + _ => panic!("Wrong") + } + match r.match_path("/logout") { + RouteMatch::Match(Route::Logout, rest) => assert_eq!(rest, ""), + _ => panic!("Wrong") + } + match r.match_path("/info") { + RouteMatch::Match(Route::Info, rest) => assert_eq!(rest, ""), + _ => panic!("Wrong") + } + match r.match_path("/logout2") { + RouteMatch::Match(Route::Info, rest) => assert_eq!(rest, ""), + _ => panic!("Wrong") + } + match r.match_path("/asdasdasd") { + RouteMatch::None => (), + _ => panic!("Wrong") + } + + match r.match_path("/login/foo/bar") { + RouteMatch::Match(Route::Login, rest) => assert_eq!(rest, "/foo/bar"), + _ => panic!("Wrong") + } + }) + } +}
\ No newline at end of file diff --git a/src/system.rs b/src/system.rs new file mode 100644 index 0000000..8eba50c --- /dev/null +++ b/src/system.rs @@ -0,0 +1,8 @@ +use std::time; + +pub fn initialize_rng_from_time() { + let r = random::default(); + let now = time::SystemTime::now(); + let nano_secs = now.duration_since(time::SystemTime::UNIX_EPOCH).unwrap().as_nanos(); + r.seed([(nano_secs >> 64) as u64, nano_secs as u64]); +} diff --git a/src/totp.rs b/src/totp.rs new file mode 100644 index 0000000..09b4503 --- /dev/null +++ b/src/totp.rs @@ -0,0 +1,30 @@ +use oath::totp_custom_time; +use oath::HashType; +use std::time::{UNIX_EPOCH, SystemTime}; + +pub fn verify(secret: &str, token: &str) -> Result<bool, &'static str> { + let time_step = 30; + let totp = |time| { + totp_custom_time(secret, 6, 0, time_step, time, &HashType::SHA512) + .map(|t| { + debug!("Generated OTP for probing {} for key {}", t, secret); + t + }) + .map(|t| format!("{:06}", t) == *token) + }; + let current_time: u64 = SystemTime::now().duration_since(UNIX_EPOCH) + .expect("Earlier than 1970-01-01 00:00:00 UTC").as_secs(); + if current_time % time_step <= 5 && totp(current_time - 30)? { + return Ok(true); + } + + if current_time % time_step >= 25 && totp(current_time + 30)? { + return Ok(true); + } + + if totp(current_time)? { + return Ok(true); + } + + Ok(false) +}
\ No newline at end of file diff --git a/test/etc/nginx.conf b/test/etc/nginx.conf new file mode 100644 index 0000000..3477353 --- /dev/null +++ b/test/etc/nginx.conf @@ -0,0 +1,35 @@ +# nginx -p . -c nginx.conf + +pid /tmp/nginx.example.pid; + +daemon off; + +events { + worker_connections 5; +} + +http { + access_log /dev/stdout; + error_log /dev/stderr; + + server { + server_name localhost; + + location /auth { + rewrite /auth/(.+) /$1 break; + proxy_pass http://127.0.0.1:8080; # This is the TOTP Server + proxy_set_header X-Totp-Secret baadf00d; + proxy_set_header X-Totp-Secret deadc0de; + } + + # This ensures that if the TOTP server returns 401 we redirect to login + error_page 401 = @error401; + location @error401 { + return 302 /auth/login$request_uri; + } + + location / { + auth_request /auth/check; + } + } +}
\ No newline at end of file diff --git a/test/nginx.sh b/test/nginx.sh new file mode 100755 index 0000000..d6926c0 --- /dev/null +++ b/test/nginx.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -x + +bwrap \ + --ro-bind /bin /bin \ + --ro-bind /usr /usr \ + --ro-bind /etc /etc \ + --ro-bind /lib /lib \ + --ro-bind /lib64 /lib64 \ + --ro-bind /run /run \ + --ro-bind etc /etc/nginx \ + --ro-bind www /usr/share/nginx/html \ + --dev /dev \ + --proc /proc \ + --dir /tmp \ + --dir /var/log/nginx \ + --dir /var/lib/nginx \ + /usr/sbin/nginx
\ No newline at end of file diff --git a/test/oathtool.sh b/test/oathtool.sh new file mode 100755 index 0000000..d1d3441 --- /dev/null +++ b/test/oathtool.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +for key in baadf00d deadc0de; do + echo -n "$key: " + oathtool --totp=sha512 $key +done
\ No newline at end of file diff --git a/test/www/index.html b/test/www/index.html new file mode 100644 index 0000000..3ad444a --- /dev/null +++ b/test/www/index.html @@ -0,0 +1,58 @@ +<!DOCTYPE html> +<html><div id="saka-gui-root" style="position: absolute; left: 0px; top: 0px; width: 100%; height: 100%; z-index: 2147483647; opacity: 1; pointer-events: none;"><div><div><style> +@font-face { + font-family: Roboto; -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased; + font-style: normal; font-weight: normal; src: url(moz-extension://727c00b7-4391-4fd7-82e7-3d51bb518a34/Roboto-Regular.tff) format('tff'); +} +.saka-hint-body { + all: initial; +position: absolute; +z-index: 2147483647; +opacity: 0.85; +font-family: Roboto, sans-serif; +font-weight: 900; +padding: 0.15rem 0.25rem; +border: 0px solid; +text-align: center; +text-decoration: none; +text-transform: uppercase; +vertical-align: middle; +font-size: 12px; +color: #3ff5d5; +background-color: #000000; +border-color: #ff0000; +box-shadow: 0 3px 1px -2px rgba(0,0,0,.2), 0 2px 2px 0 rgba(0,0,0,.14), 0 1px 5px 0 rgba(0,0,0,.12); +border-radius: 4px; +transform: translate3d(0%, -50%, 0) +} +.saka-hint-normal-char { + +} +.saka-hint-active-char { + opacity: 0.5 +}</style><div style="position: absolute; left: 0px; top: 0px;"></div></div></div></div><head> +<meta http-equiv="content-type" content="text/html; charset=windows-1252"> +<title>Welcome to nginx!</title> +<style> + body { + width: 35em; + margin: 0 auto; + font-family: Tahoma, Verdana, Arial, sans-serif; + } +</style> +</head> +<body> +<h1>Welcome to nginx!</h1> +<p>If you see this page, the nginx web server is successfully installed and +working. Further configuration is required.</p> + +<p>For online documentation and support please refer to +<a href="http://nginx.org/">nginx.org</a>.<br> +Commercial support is available at +<a href="http://nginx.com/">nginx.com</a>.</p> + +<p><em>Thank you for using nginx.</em></p> + +<p><a href="other_page.html">look here other_page.html</a></p> + +</body></html>
\ No newline at end of file diff --git a/test/www/other_page.html b/test/www/other_page.html new file mode 100644 index 0000000..49656db --- /dev/null +++ b/test/www/other_page.html @@ -0,0 +1,57 @@ +<!DOCTYPE html> +<html><div id="saka-gui-root" style="position: absolute; left: 0px; top: 0px; width: 100%; height: 100%; z-index: 2147483647; opacity: 1; pointer-events: none;"><div><div><style> +@font-face { + font-family: Roboto; -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased; + font-style: normal; font-weight: normal; src: url(moz-extension://727c00b7-4391-4fd7-82e7-3d51bb518a34/Roboto-Regular.tff) format('tff'); +} +.saka-hint-body { + all: initial; +position: absolute; +z-index: 2147483647; +opacity: 0.85; +font-family: Roboto, sans-serif; +font-weight: 900; +padding: 0.15rem 0.25rem; +border: 0px solid; +text-align: center; +text-decoration: none; +text-transform: uppercase; +vertical-align: middle; +font-size: 12px; +color: #3ff5d5; +background-color: #000000; +border-color: #ff0000; +box-shadow: 0 3px 1px -2px rgba(0,0,0,.2), 0 2px 2px 0 rgba(0,0,0,.14), 0 1px 5px 0 rgba(0,0,0,.12); +border-radius: 4px; +transform: translate3d(0%, -50%, 0) +} +.saka-hint-normal-char { + +} +.saka-hint-active-char { + opacity: 0.5 +}</style><div style="position: absolute; left: 0px; top: 0px;"></div></div></div></div><head> +<meta http-equiv="content-type" content="text/html; charset=windows-1252"> +<title>Welcome to nginx!</title> +<style> + body { + width: 35em; + margin: 0 auto; + font-family: Tahoma, Verdana, Arial, sans-serif; + } +</style> +</head> +<body> +<h1>Welcome to nginx!</h1> +<p>If you see this page, the nginx web server is successfully installed and +working. Further configuration is required.</p> + +<p>For online documentation and support please refer to +<a href="http://nginx.org/">nginx.org</a>.<br> +Commercial support is available at +<a href="http://nginx.com/">nginx.com</a>.</p> + +<p><em>Thank you for using nginx.</em></p> + + +</body></html>
\ No newline at end of file |