From 6342fcb3ede50fb7a71a14513ad1ff3bbf75a5bb Mon Sep 17 00:00:00 2001 From: Eugene Date: Sun, 26 Jun 2022 20:50:04 +0200 Subject: [PATCH] HTTP targets support (fixes #116) --- .bumpversion.cfg | 2 +- .github/dependabot.yml | 2 +- .github/workflows/build.yml | 2 +- Cargo.lock | 185 +++++++- Cargo.toml | 2 + README.md | 4 +- justfile | 10 +- warpgate-admin/Cargo.toml | 3 +- warpgate-admin/app/src/Login.svelte | 86 ---- warpgate-admin/app/src/lib/api.ts | 8 - warpgate-admin/app/src/lib/store.ts | 3 - warpgate-admin/app/src/main.ts | 9 - warpgate-admin/src/api/auth.rs | 75 ---- warpgate-admin/src/api/info.rs | 30 -- warpgate-admin/src/api/known_hosts_detail.rs | 38 +- warpgate-admin/src/api/known_hosts_list.rs | 22 +- warpgate-admin/src/api/logs.rs | 78 ++-- warpgate-admin/src/api/mod.rs | 20 +- warpgate-admin/src/api/recordings_detail.rs | 158 ++++--- warpgate-admin/src/api/sessions_detail.rs | 69 ++- warpgate-admin/src/api/sessions_list.rs | 50 +-- warpgate-admin/src/api/ssh_keys.rs | 30 +- warpgate-admin/src/api/targets_list.rs | 14 +- warpgate-admin/src/api/tickets_detail.rs | 38 +- warpgate-admin/src/api/tickets_list.rs | 80 ++-- warpgate-admin/src/api/users_list.rs | 14 +- warpgate-admin/src/helpers.rs | 28 -- warpgate-admin/src/lib.rs | 143 ++----- warpgate-admin/src/main.rs | 10 + warpgate-common/src/config.rs | 53 ++- warpgate-common/src/config_providers/file.rs | 73 ++-- warpgate-common/src/config_providers/mod.rs | 4 +- warpgate-common/src/data.rs | 2 + warpgate-common/src/lib.rs | 2 + warpgate-common/src/protocols/handle.rs | 6 +- warpgate-common/src/recordings/writer.rs | 17 +- warpgate-common/src/state.rs | 21 +- warpgate-common/src/try_macro.rs | 48 +++ warpgate-common/src/types.rs | 18 + warpgate-db-entities/src/Session.rs | 1 + warpgate-db-migrations/src/lib.rs | 2 + .../src/m00006_add_session_protocol.rs | 41 ++ warpgate-protocol-http/Cargo.toml | 29 ++ warpgate-protocol-http/src/api/auth.rs | 112 +++++ warpgate-protocol-http/src/api/info.rs | 59 +++ warpgate-protocol-http/src/api/mod.rs | 9 + .../src/api/targets_list.rs | 77 ++++ warpgate-protocol-http/src/catchall.rs | 84 ++++ warpgate-protocol-http/src/common.rs | 131 ++++++ warpgate-protocol-http/src/lib.rs | 166 ++++++++ warpgate-protocol-http/src/logging.rs | 32 ++ warpgate-protocol-http/src/main.rs | 16 + warpgate-protocol-http/src/proxy.rs | 402 ++++++++++++++++++ warpgate-protocol-http/src/session.rs | 175 ++++++++ warpgate-protocol-http/src/session_handle.rs | 64 +++ warpgate-protocol-ssh/src/client/mod.rs | 35 +- warpgate-protocol-ssh/src/lib.rs | 9 +- warpgate-protocol-ssh/src/server/mod.rs | 6 +- .../src/server/russh_handler.rs | 2 +- warpgate-protocol-ssh/src/server/session.rs | 41 +- .../app => warpgate-web}/.editorconfig | 0 .../app => warpgate-web}/.eslintrc.yaml | 1 + .../app => warpgate-web}/.gitignore | 0 warpgate-web/Cargo.toml | 11 + .../app => warpgate-web}/openapitools.json | 0 .../app => warpgate-web}/package.json | 10 +- .../public/assets/logo.svg | 0 .../src => warpgate-web/src/admin}/App.svelte | 94 ++-- .../src/admin}/CreateTicket.svelte | 13 +- .../src/admin}/Home.svelte | 23 +- .../src => warpgate-web/src/admin}/Log.svelte | 2 +- .../src/admin}/LogViewer.svelte | 4 +- .../src/admin}/Recording.svelte | 6 +- .../src/admin}/RelativeDate.svelte | 2 +- .../src => warpgate-web/src/admin}/SSH.svelte | 2 +- .../src/admin}/Session.svelte | 30 +- .../src/admin}/Targets.svelte | 14 +- .../src/admin}/Tickets.svelte | 4 +- .../app => warpgate-web/src/admin}/index.html | 2 +- warpgate-web/src/admin/index.ts | 10 + warpgate-web/src/admin/lib/api.ts | 8 + .../src/admin/lib}/openapi-schema.json | 188 ++++---- .../src => warpgate-web/src/admin}/lib/ssh.ts | 0 .../src/admin}/lib/time.ts | 0 .../player/TerminalRecordingPlayer.svelte | 8 +- warpgate-web/src/common/AsyncButton.svelte | 34 ++ warpgate-web/src/embed/EmbeddedUI.svelte | 161 +++++++ warpgate-web/src/embed/index.ts | 23 + warpgate-web/src/gateway/App.svelte | 77 ++++ warpgate-web/src/gateway/Login.svelte | 129 ++++++ warpgate-web/src/gateway/TargetList.svelte | 96 +++++ warpgate-web/src/gateway/index.html | 14 + warpgate-web/src/gateway/index.ts | 10 + warpgate-web/src/gateway/lib/api.ts | 8 + .../src/gateway/lib/openapi-schema.json | 188 ++++++++ warpgate-web/src/gateway/lib/store.ts | 8 + warpgate-web/src/gateway/login.ts | 8 + warpgate-web/src/lib.rs | 39 ++ .../app => warpgate-web}/src/theme.scss | 7 + .../app => warpgate-web}/src/vars.scss | 12 + .../app => warpgate-web}/src/vite-env.d.ts | 0 .../app => warpgate-web}/svelte.config.js | 5 +- .../app => warpgate-web}/tsconfig.json | 2 + .../app => warpgate-web}/tsconfig.node.json | 0 .../app => warpgate-web}/vite.config.ts | 12 +- .../app => warpgate-web}/yarn.lock | 0 warpgate/Cargo.toml | 1 + warpgate/src/commands/check.rs | 2 +- warpgate/src/commands/run.rs | 42 +- warpgate/src/commands/setup.rs | 27 +- 110 files changed, 3233 insertions(+), 1054 deletions(-) delete mode 100644 warpgate-admin/app/src/Login.svelte delete mode 100644 warpgate-admin/app/src/lib/api.ts delete mode 100644 warpgate-admin/app/src/lib/store.ts delete mode 100644 warpgate-admin/app/src/main.ts delete mode 100644 warpgate-admin/src/api/auth.rs delete mode 100644 warpgate-admin/src/api/info.rs delete mode 100644 warpgate-admin/src/helpers.rs create mode 100644 warpgate-admin/src/main.rs create mode 100644 warpgate-common/src/try_macro.rs create mode 100644 warpgate-db-migrations/src/m00006_add_session_protocol.rs create mode 100644 warpgate-protocol-http/Cargo.toml create mode 100644 warpgate-protocol-http/src/api/auth.rs create mode 100644 warpgate-protocol-http/src/api/info.rs create mode 100644 warpgate-protocol-http/src/api/mod.rs create mode 100644 warpgate-protocol-http/src/api/targets_list.rs create mode 100644 warpgate-protocol-http/src/catchall.rs create mode 100644 warpgate-protocol-http/src/common.rs create mode 100644 warpgate-protocol-http/src/lib.rs create mode 100644 warpgate-protocol-http/src/logging.rs create mode 100644 warpgate-protocol-http/src/main.rs create mode 100644 warpgate-protocol-http/src/proxy.rs create mode 100644 warpgate-protocol-http/src/session.rs create mode 100644 warpgate-protocol-http/src/session_handle.rs rename {warpgate-admin/app => warpgate-web}/.editorconfig (100%) rename {warpgate-admin/app => warpgate-web}/.eslintrc.yaml (99%) rename {warpgate-admin/app => warpgate-web}/.gitignore (100%) create mode 100644 warpgate-web/Cargo.toml rename {warpgate-admin/app => warpgate-web}/openapitools.json (100%) rename {warpgate-admin/app => warpgate-web}/package.json (62%) rename {warpgate-admin/app => warpgate-web}/public/assets/logo.svg (100%) rename {warpgate-admin/app/src => warpgate-web/src/admin}/App.svelte (52%) rename {warpgate-admin/app/src => warpgate-web/src/admin}/CreateTicket.svelte (88%) rename {warpgate-admin/app/src => warpgate-web/src/admin}/Home.svelte (84%) rename {warpgate-admin/app/src => warpgate-web/src/admin}/Log.svelte (72%) rename {warpgate-admin/app/src => warpgate-web/src/admin}/LogViewer.svelte (99%) rename {warpgate-admin/app/src => warpgate-web/src/admin}/Recording.svelte (78%) rename {warpgate-admin/app/src => warpgate-web/src/admin}/RelativeDate.svelte (69%) rename {warpgate-admin/app/src => warpgate-web/src/admin}/SSH.svelte (96%) rename {warpgate-admin/app/src => warpgate-web/src/admin}/Session.svelte (73%) rename {warpgate-admin/app/src => warpgate-web/src/admin}/Targets.svelte (86%) rename {warpgate-admin/app/src => warpgate-web/src/admin}/Tickets.svelte (94%) rename {warpgate-admin/app => warpgate-web/src/admin}/index.html (82%) create mode 100644 warpgate-web/src/admin/index.ts create mode 100644 warpgate-web/src/admin/lib/api.ts rename {warpgate-admin/app => warpgate-web/src/admin/lib}/openapi-schema.json (87%) rename {warpgate-admin/app/src => warpgate-web/src/admin}/lib/ssh.ts (100%) rename {warpgate-admin/app/src => warpgate-web/src/admin}/lib/time.ts (100%) rename {warpgate-admin/app/src => warpgate-web/src/admin}/player/TerminalRecordingPlayer.svelte (97%) create mode 100644 warpgate-web/src/common/AsyncButton.svelte create mode 100644 warpgate-web/src/embed/EmbeddedUI.svelte create mode 100644 warpgate-web/src/embed/index.ts create mode 100644 warpgate-web/src/gateway/App.svelte create mode 100644 warpgate-web/src/gateway/Login.svelte create mode 100644 warpgate-web/src/gateway/TargetList.svelte create mode 100644 warpgate-web/src/gateway/index.html create mode 100644 warpgate-web/src/gateway/index.ts create mode 100644 warpgate-web/src/gateway/lib/api.ts create mode 100644 warpgate-web/src/gateway/lib/openapi-schema.json create mode 100644 warpgate-web/src/gateway/lib/store.ts create mode 100644 warpgate-web/src/gateway/login.ts create mode 100644 warpgate-web/src/lib.rs rename {warpgate-admin/app => warpgate-web}/src/theme.scss (94%) rename {warpgate-admin/app => warpgate-web}/src/vars.scss (66%) rename {warpgate-admin/app => warpgate-web}/src/vite-env.d.ts (100%) rename {warpgate-admin/app => warpgate-web}/svelte.config.js (74%) rename {warpgate-admin/app => warpgate-web}/tsconfig.json (95%) rename {warpgate-admin/app => warpgate-web}/tsconfig.node.json (100%) rename {warpgate-admin/app => warpgate-web}/vite.config.ts (60%) rename {warpgate-admin/app => warpgate-web}/yarn.lock (100%) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index c621844..f078e3b 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -7,6 +7,6 @@ tag = True search = version = "{current_version}" replace = version = "{new_version}" -[bumpversion:file:warpgate-admin/Cargo.toml] +[bumpversion:file:warpgate-protocol-http/Cargo.toml] search = version = "{current_version}" replace = version = "{new_version}" diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5732ce0..38542a0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,7 +10,7 @@ updates: schedule: interval: "weekly" - package-ecosystem: "npm" - directory: "/warpgate-admin/app" + directory: "/web" labels: ["type/deps"] open-pull-requests-limit: 25 schedule: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 26a6cd4..3756fe0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,7 +35,7 @@ jobs: - name: Build admin UI run: | - just yarn openapi-client + just openapi just yarn build - name: Build diff --git a/Cargo.lock b/Cargo.lock index c5a062a..86cb088 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -905,6 +905,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ee2393c4a91429dffb4bedf19f4d6abf27d8a732c8ce4980305d782e5426d57" +[[package]] +name = "delegate" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c47a31748d9cfa641f6cccb3608385fafe261ba36054f3d40d5a3ca11eb1af" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "derive_more" version = "0.99.17" @@ -1287,7 +1298,7 @@ dependencies = [ "indexmap", "slab", "tokio", - "tokio-util", + "tokio-util 0.7.1", "tracing", ] @@ -1503,6 +1514,19 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87c48c02e0dc5e3b849a2041db3029fd066650f8f717c07bf8ed78ccb895cac" +dependencies = [ + "http", + "hyper", + "rustls", + "tokio", + "tokio-rustls", +] + [[package]] name = "hyper-timeout" version = "0.4.1" @@ -1515,6 +1539,19 @@ dependencies = [ "tokio-io-timeout", ] +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -1585,6 +1622,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "ipnet" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879d54834c8c76457ef4293a689b2a8c59b076067ad77b15efafbb05f92a592b" + [[package]] name = "itertools" version = "0.10.3" @@ -2261,7 +2304,7 @@ dependencies = [ "rand", "regex", "rust-embed", - "rustls-pemfile", + "rustls-pemfile 1.0.0", "serde", "serde_json", "serde_urlencoded", @@ -2273,7 +2316,7 @@ dependencies = [ "tokio-rustls", "tokio-stream", "tokio-tungstenite", - "tokio-util", + "tokio-util 0.7.1", "tracing", ] @@ -2572,6 +2615,48 @@ dependencies = [ "winapi", ] +[[package]] +name = "reqwest" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1f7aa4f35e5e8b4160449f51afc758f0ce6454315a9fa7d0d113e958c41eb" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-rustls", + "hyper-tls", + "ipnet", + "js-sys", + "lazy_static", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pemfile 0.3.0", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tokio-util 0.6.10", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + [[package]] name = "ring" version = "0.16.20" @@ -2753,6 +2838,27 @@ dependencies = [ "webpki", ] +[[package]] +name = "rustls-native-certs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0167bac7a9f490495f3c33013e7722b53cb087ecbe082fb0c6387c96f634ea50" +dependencies = [ + "openssl-probe", + "rustls-pemfile 1.0.0", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ee86d63972a7c661d1536fefe8c3c8407321c3df668891286de28abcd087360" +dependencies = [ + "base64", +] + [[package]] name = "rustls-pemfile" version = "1.0.0" @@ -3638,8 +3744,26 @@ checksum = "06cda1232a49558c46f8a504d5b93101d42c0bf7f911f12a105ba48168f821ae" dependencies = [ "futures-util", "log", + "rustls", + "rustls-native-certs", "tokio", + "tokio-rustls", "tungstenite", + "webpki", +] + +[[package]] +name = "tokio-util" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36943ee01a6d67977dd3f84a5a1d2efeb4ada3a1ae771cadfaa535d9d9fc6507" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "log", + "pin-project-lite", + "tokio", ] [[package]] @@ -3689,7 +3813,7 @@ dependencies = [ "prost-derive", "tokio", "tokio-stream", - "tokio-util", + "tokio-util 0.7.1", "tower", "tower-layer", "tower-service", @@ -3724,7 +3848,7 @@ dependencies = [ "rand", "slab", "tokio", - "tokio-util", + "tokio-util 0.7.1", "tower-layer", "tower-service", "tracing", @@ -3854,10 +3978,12 @@ dependencies = [ "httparse", "log", "rand", + "rustls", "sha-1", "thiserror", "url", "utf-8", + "webpki", ] [[package]] @@ -4048,6 +4174,7 @@ dependencies = [ "tracing-subscriber", "warpgate-admin", "warpgate-common", + "warpgate-protocol-http", "warpgate-protocol-ssh", ] @@ -4076,6 +4203,7 @@ dependencies = [ "warpgate-common", "warpgate-db-entities", "warpgate-protocol-ssh", + "warpgate-web", ] [[package]] @@ -4135,6 +4263,34 @@ dependencies = [ "uuid", ] +[[package]] +name = "warpgate-protocol-http" +version = "0.2.5" +dependencies = [ + "anyhow", + "async-trait", + "cookie", + "data-encoding", + "delegate", + "futures", + "http", + "lazy_static", + "percent-encoding", + "poem", + "poem-openapi", + "reqwest", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite", + "tracing", + "uuid", + "warpgate-admin", + "warpgate-common", + "warpgate-db-entities", + "warpgate-web", +] + [[package]] name = "warpgate-protocol-ssh" version = "0.1.0" @@ -4158,6 +4314,16 @@ dependencies = [ "warpgate-db-entities", ] +[[package]] +name = "warpgate-web" +version = "0.1.0" +dependencies = [ + "rust-embed", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "wasi" version = "0.10.0+wasi-snapshot-preview1" @@ -4339,6 +4505,15 @@ version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d19538ccc21819d01deaf88d6a17eae6596a12e9aafdbb97916fb49896d89de9" +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "yaml-rust" version = "0.4.5" diff --git a/Cargo.toml b/Cargo.toml index 4940e8c..a7fc1ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,9 @@ members = [ "warpgate-common", "warpgate-db-migrations", "warpgate-db-entities", + "warpgate-protocol-http", "warpgate-protocol-ssh", + "warpgate-web", ] default-members = ["warpgate"] diff --git a/README.md b/README.md index f2e7700..99b2078 100644 --- a/README.md +++ b/README.md @@ -58,11 +58,11 @@ You can use the web interface to view the live session list, review session reco ## Contributing / building from source -* You'll need nightly Rust (will be installed automatically), NodeJS and Yarn +* You'll need Rust, NodeJS and Yarn * Clone the repo * [Just](https://github.com/casey/just) is used to run tasks - install it: `cargo install just` * Install the admin UI deps: `just yarn` -* Build the API SDK: `just openapi-client` +* Build the API SDK: `just openapi` * Build the frontend: `just yarn build` * Build Warpgate: `cargo build` (optionally `--release`) diff --git a/justfile b/justfile index 8af9fae..84e8730 100644 --- a/justfile +++ b/justfile @@ -13,21 +13,21 @@ clippy *ARGS: for p in {{projects}}; do cargo clippy -p $p {{ARGS}}; done yarn *ARGS: - cd warpgate-admin/app/ && yarn {{ARGS}} + cd warpgate-web && yarn {{ARGS}} migrate *ARGS: cargo run -p warpgate-db-migrations -- {{ARGS}} lint: - cd warpgate-admin/app/ && yarn run lint + cd warpgate-web && yarn run lint svelte-check: - cd warpgate-admin/app/ && yarn run check + cd warpgate-web && yarn run check openapi-all: - cd warpgate-admin/app/ && yarn openapi-schema && yarn openapi-client + cd warpgate-web && yarn openapi:schema:admin && yarn openapi:schema:gateway && yarn openapi:client:admin && yarn openapi:client:gateway openapi: - cd warpgate-admin/app/ && yarn openapi-client + cd warpgate-web && yarn openapi:client:admin && yarn openapi:client:gateway cleanup: (fix "--allow-dirty") (clippy "--fix" "--allow-dirty") fmt svelte-check lint diff --git a/warpgate-admin/Cargo.toml b/warpgate-admin/Cargo.toml index cf1e6a6..e27b8eb 100644 --- a/warpgate-admin/Cargo.toml +++ b/warpgate-admin/Cargo.toml @@ -12,7 +12,7 @@ chrono = "0.4" futures = "0.3" hex = "0.4" mime_guess = {version = "2.0", default_features = false} -poem = {version = "^1.3.24", features = ["cookie", "session", "anyhow", "rustls", "websocket", "embed"]} +poem = {version = "^1.3.30", features = ["cookie", "session", "anyhow", "websocket"]} poem-openapi = {version = "^1.3.30", features = ["swagger-ui", "chrono", "uuid", "static-files"]} russh-keys = {version = "0.22.0-beta.2", features = ["openssl"]} rust-embed = "6.3" @@ -26,3 +26,4 @@ uuid = {version = "0.8", features = ["v4", "serde"]} warpgate-common = {version = "*", path = "../warpgate-common"} warpgate-db-entities = {version = "*", path = "../warpgate-db-entities"} warpgate-protocol-ssh = {version = "*", path = "../warpgate-protocol-ssh"} +warpgate-web = {version = "*", path = "../warpgate-web"} diff --git a/warpgate-admin/app/src/Login.svelte b/warpgate-admin/app/src/Login.svelte deleted file mode 100644 index def97c4..0000000 --- a/warpgate-admin/app/src/Login.svelte +++ /dev/null @@ -1,86 +0,0 @@ - - -
-
- -
-

Welcome

-
- - - - - - - - - - - - - {#if incorrectCredentials} - Incorrect credentials - {/if} - {#if error} - {error} - {/if} - -
-
- diff --git a/warpgate-admin/app/src/lib/api.ts b/warpgate-admin/app/src/lib/api.ts deleted file mode 100644 index f703c49..0000000 --- a/warpgate-admin/app/src/lib/api.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { DefaultApi, Configuration } from '../../api-client/dist' - -const configuration = new Configuration({ - basePath: '/api', -}) - -export const api = new DefaultApi(configuration) -export * from '../../api-client' diff --git a/warpgate-admin/app/src/lib/store.ts b/warpgate-admin/app/src/lib/store.ts deleted file mode 100644 index ec292eb..0000000 --- a/warpgate-admin/app/src/lib/store.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { writable } from 'svelte/store' - -export const authenticatedUsername = writable(null) diff --git a/warpgate-admin/app/src/main.ts b/warpgate-admin/app/src/main.ts deleted file mode 100644 index 8bb81a6..0000000 --- a/warpgate-admin/app/src/main.ts +++ /dev/null @@ -1,9 +0,0 @@ -import '@fontsource/work-sans' -import './theme.scss' -import App from './App.svelte' - -const app = new App({ - target: document.getElementById('app')!, -}) - -export default app diff --git a/warpgate-admin/src/api/auth.rs b/warpgate-admin/src/api/auth.rs deleted file mode 100644 index ba35a9a..0000000 --- a/warpgate-admin/src/api/auth.rs +++ /dev/null @@ -1,75 +0,0 @@ -use crate::helpers::ApiResult; -use poem::session::Session; -use poem::web::Data; -use poem_openapi::payload::Json; -use poem_openapi::{ApiResponse, Object, OpenApi}; -use std::sync::Arc; -use tokio::sync::Mutex; -use warpgate_common::{AuthCredential, AuthResult, ConfigProvider, Secret}; - -pub struct Api; - -#[derive(Object)] -struct LoginRequest { - username: String, - password: String, -} - -#[derive(ApiResponse)] -enum LoginResponse { - #[oai(status = 201)] - Success, - - #[oai(status = 401)] - Failure, -} - -#[derive(ApiResponse)] -enum LogoutResponse { - #[oai(status = 201)] - Success, -} - -#[OpenApi] -impl Api { - #[oai(path = "/auth/login", method = "post", operation_id = "login")] - async fn api_auth_login( - &self, - session: &Session, - config_provider: Data<&Arc>>, - body: Json, - ) -> ApiResult { - let mut config_provider = config_provider.lock().await; - let result = config_provider - .authorize( - &body.username, - &[AuthCredential::Password(Secret::new(body.password.clone()))], - ) - .await - .map_err(|e| e.context("Failed to authorize user"))?; - match result { - AuthResult::Accepted { username } => { - let targets = config_provider.list_targets().await?; - for target in targets { - if target.web_admin.is_some() - && config_provider - .authorize_target(&username, &target.name) - .await? - { - session.set("username", username); - return Ok(LoginResponse::Success); - } - } - Ok(LoginResponse::Failure) - } - AuthResult::Rejected => Ok(LoginResponse::Failure), - AuthResult::OTPNeeded => Ok(LoginResponse::Failure), // TODO - } - } - - #[oai(path = "/auth/logout", method = "post", operation_id = "logout")] - async fn api_auth_logout(&self, session: &Session) -> ApiResult { - session.clear(); - Ok(LogoutResponse::Success) - } -} diff --git a/warpgate-admin/src/api/info.rs b/warpgate-admin/src/api/info.rs deleted file mode 100644 index 226cc86..0000000 --- a/warpgate-admin/src/api/info.rs +++ /dev/null @@ -1,30 +0,0 @@ -use crate::helpers::ApiResult; -use poem::session::Session; -use poem_openapi::payload::Json; -use poem_openapi::{ApiResponse, Object, OpenApi}; -use serde::Serialize; - -pub struct Api; - -#[derive(Serialize, Object)] -pub struct Info { - version: String, - username: Option, -} - -#[derive(ApiResponse)] -enum InstanceInfoResponse { - #[oai(status = 200)] - Ok(Json), -} - -#[OpenApi] -impl Api { - #[oai(path = "/info", method = "get", operation_id = "get_info")] - async fn api_get_info(&self, session: &Session) -> ApiResult { - Ok(InstanceInfoResponse::Ok(Json(Info { - version: env!("CARGO_PKG_VERSION").to_string(), - username: session.get::("username"), - }))) - } -} diff --git a/warpgate-admin/src/api/known_hosts_detail.rs b/warpgate-admin/src/api/known_hosts_detail.rs index d69f532..9ca5515 100644 --- a/warpgate-admin/src/api/known_hosts_detail.rs +++ b/warpgate-admin/src/api/known_hosts_detail.rs @@ -1,5 +1,3 @@ -use crate::helpers::{authorized, ApiResult}; -use poem::session::Session; use poem::web::Data; use poem_openapi::param::Path; use poem_openapi::{ApiResponse, OpenApi}; @@ -29,28 +27,24 @@ impl Api { &self, db: Data<&Arc>>, id: Path, - session: &Session, - ) -> ApiResult { - authorized(session, || async move { - use warpgate_db_entities::KnownHost; - let db = db.lock().await; + ) -> poem::Result { + use warpgate_db_entities::KnownHost; + let db = db.lock().await; - let known_host = KnownHost::Entity::find_by_id(id.0) - .one(&*db) - .await - .map_err(poem::error::InternalServerError)?; + let known_host = KnownHost::Entity::find_by_id(id.0) + .one(&*db) + .await + .map_err(poem::error::InternalServerError)?; - match known_host { - Some(known_host) => { - known_host - .delete(&*db) - .await - .map_err(poem::error::InternalServerError)?; - Ok(DeleteSSHKnownHostResponse::Deleted) - } - None => Ok(DeleteSSHKnownHostResponse::NotFound), + match known_host { + Some(known_host) => { + known_host + .delete(&*db) + .await + .map_err(poem::error::InternalServerError)?; + Ok(DeleteSSHKnownHostResponse::Deleted) } - }) - .await + None => Ok(DeleteSSHKnownHostResponse::NotFound), + } } } diff --git a/warpgate-admin/src/api/known_hosts_list.rs b/warpgate-admin/src/api/known_hosts_list.rs index be41e9a..a4bedc7 100644 --- a/warpgate-admin/src/api/known_hosts_list.rs +++ b/warpgate-admin/src/api/known_hosts_list.rs @@ -1,5 +1,3 @@ -use crate::helpers::{authorized, ApiResult}; -use poem::session::Session; use poem::web::Data; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, OpenApi}; @@ -26,18 +24,14 @@ impl Api { async fn api_ssh_get_all_known_hosts( &self, db: Data<&Arc>>, - session: &Session, - ) -> ApiResult { - authorized(session, || async move { - use warpgate_db_entities::KnownHost; + ) -> poem::Result { + use warpgate_db_entities::KnownHost; - let db = db.lock().await; - let hosts = KnownHost::Entity::find() - .all(&*db) - .await - .map_err(poem::error::InternalServerError)?; - Ok(GetSSHKnownHostsResponse::Ok(Json(hosts))) - }) - .await + let db = db.lock().await; + let hosts = KnownHost::Entity::find() + .all(&*db) + .await + .map_err(poem::error::InternalServerError)?; + Ok(GetSSHKnownHostsResponse::Ok(Json(hosts))) } } diff --git a/warpgate-admin/src/api/logs.rs b/warpgate-admin/src/api/logs.rs index a948eea..08998fc 100644 --- a/warpgate-admin/src/api/logs.rs +++ b/warpgate-admin/src/api/logs.rs @@ -1,6 +1,4 @@ -use crate::helpers::{authorized, ApiResult}; use chrono::{DateTime, Utc}; -use poem::session::Session; use poem::web::Data; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, Object, OpenApi}; @@ -35,48 +33,44 @@ impl Api { &self, db: Data<&Arc>>, body: Json, - session: &Session, - ) -> ApiResult { - authorized(session, || async move { - use warpgate_db_entities::LogEntry; + ) -> poem::Result { + use warpgate_db_entities::LogEntry; - let db = db.lock().await; - let mut q = LogEntry::Entity::find() - .order_by_desc(LogEntry::Column::Timestamp) - .limit(body.limit.unwrap_or(100)); + let db = db.lock().await; + let mut q = LogEntry::Entity::find() + .order_by_desc(LogEntry::Column::Timestamp) + .limit(body.limit.unwrap_or(100)); - if let Some(before) = body.before { - q = q.filter(LogEntry::Column::Timestamp.lt(before)); - } - if let Some(after) = body.after { - q = q - .filter(LogEntry::Column::Timestamp.gt(after)) - .order_by_asc(LogEntry::Column::Timestamp); - } - if let Some(ref session_id) = body.session_id { - q = q.filter(LogEntry::Column::SessionId.eq(*session_id)); - } - if let Some(ref username) = body.username { - q = q.filter(LogEntry::Column::SessionId.eq(username.clone())); - } - if let Some(ref search) = body.search { - q = q.filter( - LogEntry::Column::Text - .contains(search) - .or(LogEntry::Column::Username.contains(search)), - ); - } + if let Some(before) = body.before { + q = q.filter(LogEntry::Column::Timestamp.lt(before)); + } + if let Some(after) = body.after { + q = q + .filter(LogEntry::Column::Timestamp.gt(after)) + .order_by_asc(LogEntry::Column::Timestamp); + } + if let Some(ref session_id) = body.session_id { + q = q.filter(LogEntry::Column::SessionId.eq(*session_id)); + } + if let Some(ref username) = body.username { + q = q.filter(LogEntry::Column::SessionId.eq(username.clone())); + } + if let Some(ref search) = body.search { + q = q.filter( + LogEntry::Column::Text + .contains(search) + .or(LogEntry::Column::Username.contains(search)), + ); + } - let logs = q - .all(&*db) - .await - .map_err(poem::error::InternalServerError)?; - let logs = logs - .into_iter() - .map(Into::into) - .collect::>(); - Ok(GetLogsResponse::Ok(Json(logs))) - }) - .await + let logs = q + .all(&*db) + .await + .map_err(poem::error::InternalServerError)?; + let logs = logs + .into_iter() + .map(Into::into) + .collect::>(); + Ok(GetLogsResponse::Ok(Json(logs))) } } diff --git a/warpgate-admin/src/api/mod.rs b/warpgate-admin/src/api/mod.rs index dbb6856..f098273 100644 --- a/warpgate-admin/src/api/mod.rs +++ b/warpgate-admin/src/api/mod.rs @@ -1,5 +1,5 @@ -pub mod auth; -pub mod info; +use poem_openapi::OpenApi; + pub mod known_hosts_detail; pub mod known_hosts_list; pub mod logs; @@ -11,3 +11,19 @@ pub mod targets_list; pub mod tickets_detail; pub mod tickets_list; pub mod users_list; + +pub fn get() -> impl OpenApi { + ( + sessions_list::Api, + sessions_detail::Api, + recordings_detail::Api, + users_list::Api, + targets_list::Api, + tickets_list::Api, + tickets_detail::Api, + known_hosts_list::Api, + known_hosts_detail::Api, + ssh_keys::Api, + logs::Api, + ) +} diff --git a/warpgate-admin/src/api/recordings_detail.rs b/warpgate-admin/src/api/recordings_detail.rs index 271d81e..7aedd18 100644 --- a/warpgate-admin/src/api/recordings_detail.rs +++ b/warpgate-admin/src/api/recordings_detail.rs @@ -1,8 +1,6 @@ -use crate::helpers::{authorized, ApiResult}; use bytes::Bytes; use futures::{SinkExt, StreamExt}; use poem::error::{InternalServerError, NotFoundError}; -use poem::session::Session; use poem::web::websocket::{Message, WebSocket}; use poem::web::Data; use poem::{handler, IntoResponse}; @@ -41,22 +39,18 @@ impl Api { &self, db: Data<&Arc>>, id: Path, - session: &Session, - ) -> ApiResult { - authorized(session, || async move { - let db = db.lock().await; + ) -> poem::Result { + let db = db.lock().await; - let recording = Recording::Entity::find_by_id(id.0) - .one(&*db) - .await - .map_err(InternalServerError)?; + let recording = Recording::Entity::find_by_id(id.0) + .one(&*db) + .await + .map_err(InternalServerError)?; - match recording { - Some(recording) => Ok(GetRecordingResponse::Ok(Json(recording))), - None => Ok(GetRecordingResponse::NotFound), - } - }) - .await + match recording { + Some(recording) => Ok(GetRecordingResponse::Ok(Json(recording))), + None => Ok(GetRecordingResponse::NotFound), + } } } @@ -65,62 +59,58 @@ pub async fn api_get_recording_cast( db: Data<&Arc>>, recordings: Data<&Arc>>, id: poem::web::Path, - session: &Session, -) -> ApiResult { - authorized(session, || async move { - let db = db.lock().await; +) -> poem::Result { + let db = db.lock().await; - let recording = Recording::Entity::find_by_id(id.0) - .one(&*db) - .await - .map_err(InternalServerError)?; + let recording = Recording::Entity::find_by_id(id.0) + .one(&*db) + .await + .map_err(InternalServerError)?; - let Some(recording) = recording else { + let Some(recording) = recording else { return Err(NotFoundError.into()) }; - if recording.kind != RecordingKind::Terminal { - return Err(NotFoundError.into()); + if recording.kind != RecordingKind::Terminal { + return Err(NotFoundError.into()); + } + + let path = { + recordings + .lock() + .await + .path_for(&recording.session_id, &recording.name) + }; + + let mut response = vec![]; //String::new(); + + let mut last_size = (0, 0); + let file = File::open(&path).await.map_err(InternalServerError)?; + let reader = BufReader::new(file); + let mut lines = reader.lines(); + while let Some(line) = lines.next_line().await.map_err(InternalServerError)? { + let entry: TerminalRecordingItem = + serde_json::from_str(&line[..]).map_err(InternalServerError)?; + let asciicast: AsciiCast = entry.into(); + response.push(serde_json::to_string(&asciicast).map_err(InternalServerError)?); + if let AsciiCast::Header { width, height, .. } = asciicast { + last_size = (width, height); } + } - let path = { - recordings - .lock() - .await - .path_for(&recording.session_id, &recording.name) - }; + response.insert( + 0, + serde_json::to_string(&AsciiCast::Header { + time: 0.0, + version: 2, + width: last_size.0, + height: last_size.1, + title: recording.name, + }) + .map_err(InternalServerError)?, + ); - let mut response = vec![]; //String::new(); - - let mut last_size = (0, 0); - let file = File::open(&path).await.map_err(InternalServerError)?; - let reader = BufReader::new(file); - let mut lines = reader.lines(); - while let Some(line) = lines.next_line().await.map_err(InternalServerError)? { - let entry: TerminalRecordingItem = - serde_json::from_str(&line[..]).map_err(InternalServerError)?; - let asciicast: AsciiCast = entry.into(); - response.push(serde_json::to_string(&asciicast).map_err(InternalServerError)?); - if let AsciiCast::Header { width, height, .. } = asciicast { - last_size = (width, height); - } - } - - response.insert( - 0, - serde_json::to_string(&AsciiCast::Header { - time: 0.0, - version: 2, - width: last_size.0, - height: last_size.1, - title: recording.name, - }) - .map_err(InternalServerError)?, - ); - - Ok(response.join("\n")) - }) - .await + Ok(response.join("\n")) } #[handler] @@ -128,36 +118,32 @@ pub async fn api_get_recording_tcpdump( db: Data<&Arc>>, recordings: Data<&Arc>>, id: poem::web::Path, - session: &Session, -) -> ApiResult { - authorized(session, || async move { - let db = db.lock().await; +) -> poem::Result { + let db = db.lock().await; - let recording = Recording::Entity::find_by_id(id.0) - .one(&*db) - .await - .map_err(poem::error::InternalServerError)?; + let recording = Recording::Entity::find_by_id(id.0) + .one(&*db) + .await + .map_err(poem::error::InternalServerError)?; - let Some(recording) = recording else { + let Some(recording) = recording else { return Err(NotFoundError.into()) }; - if recording.kind != RecordingKind::Traffic { - return Err(NotFoundError.into()); - } + if recording.kind != RecordingKind::Traffic { + return Err(NotFoundError.into()); + } - let path = { - recordings - .lock() - .await - .path_for(&recording.session_id, &recording.name) - }; + let path = { + recordings + .lock() + .await + .path_for(&recording.session_id, &recording.name) + }; - let content = std::fs::read(path).map_err(InternalServerError)?; + let content = std::fs::read(path).map_err(InternalServerError)?; - Ok(Bytes::from(content)) - }) - .await + Ok(Bytes::from(content)) } #[handler] diff --git a/warpgate-admin/src/api/sessions_detail.rs b/warpgate-admin/src/api/sessions_detail.rs index 45a5df0..f625dcb 100644 --- a/warpgate-admin/src/api/sessions_detail.rs +++ b/warpgate-admin/src/api/sessions_detail.rs @@ -1,4 +1,3 @@ -use crate::helpers::{authorized, ApiResult}; use poem::web::Data; use poem_openapi::param::Path; use poem_openapi::payload::Json; @@ -42,22 +41,18 @@ impl Api { &self, db: Data<&Arc>>, id: Path, - session: &poem::session::Session, - ) -> ApiResult { - authorized(session, || async move { - let db = db.lock().await; + ) -> poem::Result { + let db = db.lock().await; - let session = Session::Entity::find_by_id(id.0) - .one(&*db) - .await - .map_err(poem::error::InternalServerError)?; + let session = Session::Entity::find_by_id(id.0) + .one(&*db) + .await + .map_err(poem::error::InternalServerError)?; - match session { - Some(session) => Ok(GetSessionResponse::Ok(Json(session.into()))), - None => Ok(GetSessionResponse::NotFound), - } - }) - .await + match session { + Some(session) => Ok(GetSessionResponse::Ok(Json(session.into()))), + None => Ok(GetSessionResponse::NotFound), + } } #[oai( @@ -69,19 +64,15 @@ impl Api { &self, db: Data<&Arc>>, id: Path, - session: &poem::session::Session, - ) -> ApiResult { - authorized(session, || async move { - let db = db.lock().await; - let recordings: Vec = Recording::Entity::find() - .order_by_desc(Recording::Column::Started) - .filter(Recording::Column::SessionId.eq(id.0)) - .all(&*db) - .await - .map_err(poem::error::InternalServerError)?; - Ok(GetSessionRecordingsResponse::Ok(Json(recordings))) - }) - .await + ) -> poem::Result { + let db = db.lock().await; + let recordings: Vec = Recording::Entity::find() + .order_by_desc(Recording::Column::Started) + .filter(Recording::Column::SessionId.eq(id.0)) + .all(&*db) + .await + .map_err(poem::error::InternalServerError)?; + Ok(GetSessionRecordingsResponse::Ok(Json(recordings))) } #[oai( @@ -93,19 +84,15 @@ impl Api { &self, state: Data<&Arc>>, id: Path, - session: &poem::session::Session, - ) -> ApiResult { - authorized(session, || async move { - let state = state.lock().await; + ) -> poem::Result { + let state = state.lock().await; - if let Some(s) = state.sessions.get(&id) { - let mut session = s.lock().await; - session.handle.close(); - Ok(CloseSessionResponse::Ok) - } else { - Ok(CloseSessionResponse::NotFound) - } - }) - .await + if let Some(s) = state.sessions.get(&id) { + let mut session = s.lock().await; + session.handle.close(); + Ok(CloseSessionResponse::Ok) + } else { + Ok(CloseSessionResponse::NotFound) + } } } diff --git a/warpgate-admin/src/api/sessions_list.rs b/warpgate-admin/src/api/sessions_list.rs index 44a4461..2a97a76 100644 --- a/warpgate-admin/src/api/sessions_list.rs +++ b/warpgate-admin/src/api/sessions_list.rs @@ -1,5 +1,3 @@ -use crate::helpers::{authorized, ApiResult}; -use poem::session::Session; use poem::web::Data; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, OpenApi}; @@ -28,24 +26,20 @@ impl Api { async fn api_get_all_sessions( &self, db: Data<&Arc>>, - session: &Session, - ) -> ApiResult { - authorized(session, || async move { - use warpgate_db_entities::Session; + ) -> poem::Result { + use warpgate_db_entities::Session; - let db = db.lock().await; - let sessions = Session::Entity::find() - .order_by_desc(Session::Column::Started) - .all(&*db) - .await - .map_err(poem::error::InternalServerError)?; - let sessions = sessions - .into_iter() - .map(Into::into) - .collect::>(); - Ok(GetSessionsResponse::Ok(Json(sessions))) - }) - .await + let db = db.lock().await; + let sessions = Session::Entity::find() + .order_by_desc(Session::Column::Started) + .all(&*db) + .await + .map_err(poem::error::InternalServerError)?; + let sessions = sessions + .into_iter() + .map(Into::into) + .collect::>(); + Ok(GetSessionsResponse::Ok(Json(sessions))) } #[oai( @@ -56,18 +50,14 @@ impl Api { async fn api_close_all_sessions( &self, state: Data<&Arc>>, - session: &Session, - ) -> ApiResult { - authorized(session, || async move { - let state = state.lock().await; + ) -> poem::Result { + let state = state.lock().await; - for s in state.sessions.values() { - let mut session = s.lock().await; - session.handle.close(); - } + for s in state.sessions.values() { + let mut session = s.lock().await; + session.handle.close(); + } - Ok(CloseAllSessionsResponse::Ok) - }) - .await + Ok(CloseAllSessionsResponse::Ok) } } diff --git a/warpgate-admin/src/api/ssh_keys.rs b/warpgate-admin/src/api/ssh_keys.rs index 30b6114..102d3c1 100644 --- a/warpgate-admin/src/api/ssh_keys.rs +++ b/warpgate-admin/src/api/ssh_keys.rs @@ -1,5 +1,3 @@ -use crate::helpers::{authorized, ApiResult}; -use poem::session::Session; use poem::web::Data; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, Object, OpenApi}; @@ -33,22 +31,18 @@ impl Api { async fn api_ssh_get_own_keys( &self, config: Data<&Arc>>, - session: &Session, - ) -> ApiResult { - authorized(session, || async move { - let config = config.lock().await; - let keys = warpgate_protocol_ssh::load_client_keys(&config) - .map_err(poem::error::InternalServerError)?; + ) -> poem::Result { + let config = config.lock().await; + let keys = warpgate_protocol_ssh::load_client_keys(&config) + .map_err(poem::error::InternalServerError)?; - let keys = keys - .into_iter() - .map(|k| SSHKey { - kind: k.name().to_owned(), - public_key_base64: k.public_key_base64().replace('\n', "").replace('\r', ""), - }) - .collect(); - Ok(GetSSHOwnKeysResponse::Ok(Json(keys))) - }) - .await + let keys = keys + .into_iter() + .map(|k| SSHKey { + kind: k.name().to_owned(), + public_key_base64: k.public_key_base64().replace('\n', "").replace('\r', ""), + }) + .collect(); + Ok(GetSSHOwnKeysResponse::Ok(Json(keys))) } } diff --git a/warpgate-admin/src/api/targets_list.rs b/warpgate-admin/src/api/targets_list.rs index 5339929..94ae6bd 100644 --- a/warpgate-admin/src/api/targets_list.rs +++ b/warpgate-admin/src/api/targets_list.rs @@ -1,5 +1,3 @@ -use crate::helpers::{authorized, ApiResult}; -use poem::session::Session; use poem::web::Data; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, OpenApi}; @@ -21,13 +19,9 @@ impl Api { async fn api_get_all_targets( &self, config_provider: Data<&Arc>>, - session: &Session, - ) -> ApiResult { - authorized(session, || async move { - let mut targets = config_provider.lock().await.list_targets().await?; - targets.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(GetTargetsResponse::Ok(Json(targets))) - }) - .await + ) -> poem::Result { + let mut targets = config_provider.lock().await.list_targets().await?; + targets.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(GetTargetsResponse::Ok(Json(targets))) } } diff --git a/warpgate-admin/src/api/tickets_detail.rs b/warpgate-admin/src/api/tickets_detail.rs index e982425..4c84079 100644 --- a/warpgate-admin/src/api/tickets_detail.rs +++ b/warpgate-admin/src/api/tickets_detail.rs @@ -1,5 +1,3 @@ -use crate::helpers::{authorized, ApiResult}; -use poem::session::Session; use poem::web::Data; use poem_openapi::param::Path; use poem_openapi::{ApiResponse, OpenApi}; @@ -30,28 +28,24 @@ impl Api { &self, db: Data<&Arc>>, id: Path, - session: &Session, - ) -> ApiResult { - authorized(session, || async move { - use warpgate_db_entities::Ticket; - let db = db.lock().await; + ) -> poem::Result { + use warpgate_db_entities::Ticket; + let db = db.lock().await; - let ticket = Ticket::Entity::find_by_id(id.0) - .one(&*db) - .await - .map_err(poem::error::InternalServerError)?; + let ticket = Ticket::Entity::find_by_id(id.0) + .one(&*db) + .await + .map_err(poem::error::InternalServerError)?; - match ticket { - Some(ticket) => { - ticket - .delete(&*db) - .await - .map_err(poem::error::InternalServerError)?; - Ok(DeleteTicketResponse::Deleted) - } - None => Ok(DeleteTicketResponse::NotFound), + match ticket { + Some(ticket) => { + ticket + .delete(&*db) + .await + .map_err(poem::error::InternalServerError)?; + Ok(DeleteTicketResponse::Deleted) } - }) - .await + None => Ok(DeleteTicketResponse::NotFound), + } } } diff --git a/warpgate-admin/src/api/tickets_list.rs b/warpgate-admin/src/api/tickets_list.rs index e333e8f..f10403a 100644 --- a/warpgate-admin/src/api/tickets_list.rs +++ b/warpgate-admin/src/api/tickets_list.rs @@ -1,6 +1,4 @@ -use crate::helpers::{authorized, ApiResult}; use anyhow::Context; -use poem::session::Session; use poem::web::Data; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, Object, OpenApi}; @@ -47,23 +45,19 @@ impl Api { async fn api_get_all_tickets( &self, db: Data<&Arc>>, - session: &Session, - ) -> ApiResult { - authorized(session, || async move { - use warpgate_db_entities::Ticket; + ) -> poem::Result { + use warpgate_db_entities::Ticket; - let db = db.lock().await; - let tickets = Ticket::Entity::find() - .all(&*db) - .await - .map_err(poem::error::InternalServerError)?; - let tickets = tickets - .into_iter() - .map(Into::into) - .collect::>(); - Ok(GetTicketsResponse::Ok(Json(tickets))) - }) - .await + let db = db.lock().await; + let tickets = Ticket::Entity::find() + .all(&*db) + .await + .map_err(poem::error::InternalServerError)?; + let tickets = tickets + .into_iter() + .map(Into::into) + .collect::>(); + Ok(GetTicketsResponse::Ok(Json(tickets))) } #[oai(path = "/tickets", method = "post", operation_id = "create_ticket")] @@ -71,36 +65,32 @@ impl Api { &self, db: Data<&Arc>>, body: Json, - session: &Session, - ) -> ApiResult { - authorized(session, || async move { - use warpgate_db_entities::Ticket; + ) -> poem::Result { + use warpgate_db_entities::Ticket; - if body.username.is_empty() { - return Ok(CreateTicketResponse::BadRequest(Json("username".into()))); - } - if body.target_name.is_empty() { - return Ok(CreateTicketResponse::BadRequest(Json("target_name".into()))); - } + if body.username.is_empty() { + return Ok(CreateTicketResponse::BadRequest(Json("username".into()))); + } + if body.target_name.is_empty() { + return Ok(CreateTicketResponse::BadRequest(Json("target_name".into()))); + } - let db = db.lock().await; - let secret = generate_ticket_secret(); - let values = Ticket::ActiveModel { - id: Set(Uuid::new_v4()), - secret: Set(secret.expose_secret().to_string()), - username: Set(body.username.clone()), - target: Set(body.target_name.clone()), - created: Set(chrono::Utc::now()), - ..Default::default() - }; + let db = db.lock().await; + let secret = generate_ticket_secret(); + let values = Ticket::ActiveModel { + id: Set(Uuid::new_v4()), + secret: Set(secret.expose_secret().to_string()), + username: Set(body.username.clone()), + target: Set(body.target_name.clone()), + created: Set(chrono::Utc::now()), + ..Default::default() + }; - let ticket = values.insert(&*db).await.context("Error saving ticket")?; + let ticket = values.insert(&*db).await.context("Error saving ticket")?; - Ok(CreateTicketResponse::Created(Json(TicketAndSecret { - secret: secret.expose_secret().to_string(), - ticket, - }))) - }) - .await + Ok(CreateTicketResponse::Created(Json(TicketAndSecret { + secret: secret.expose_secret().to_string(), + ticket, + }))) } } diff --git a/warpgate-admin/src/api/users_list.rs b/warpgate-admin/src/api/users_list.rs index d9ccae0..f206adf 100644 --- a/warpgate-admin/src/api/users_list.rs +++ b/warpgate-admin/src/api/users_list.rs @@ -1,5 +1,3 @@ -use crate::helpers::{authorized, ApiResult}; -use poem::session::Session; use poem::web::Data; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, OpenApi}; @@ -21,13 +19,9 @@ impl Api { async fn api_get_all_users( &self, config_provider: Data<&Arc>>, - session: &Session, - ) -> ApiResult { - authorized(session, || async move { - let mut users = config_provider.lock().await.list_users().await?; - users.sort_by(|a, b| a.username.cmp(&b.username)); - Ok(GetUsersResponse::Ok(Json(users))) - }) - .await + ) -> poem::Result { + let mut users = config_provider.lock().await.list_users().await?; + users.sort_by(|a, b| a.username.cmp(&b.username)); + Ok(GetUsersResponse::Ok(Json(users))) } } diff --git a/warpgate-admin/src/helpers.rs b/warpgate-admin/src/helpers.rs deleted file mode 100644 index 4e5ee74..0000000 --- a/warpgate-admin/src/helpers.rs +++ /dev/null @@ -1,28 +0,0 @@ -use poem::http::StatusCode; -use poem::session::Session; - -pub type ApiResult = poem::Result; - -pub trait SessionExt { - fn is_authorized(&self) -> bool; -} - -impl SessionExt for Session { - fn is_authorized(&self) -> bool { - self.get::("username").is_some() - } -} - -pub async fn authorized(session: &Session, f: FN) -> ApiResult -where - FN: FnOnce() -> FT, - FT: futures::Future>, -{ - if !session.is_authorized() { - return Err(poem::Error::from_string( - "Unauthorized", - StatusCode::UNAUTHORIZED, - )); - } - f().await -} diff --git a/warpgate-admin/src/lib.rs b/warpgate-admin/src/lib.rs index 165d8da..ce1ec47 100644 --- a/warpgate-admin/src/lib.rs +++ b/warpgate-admin/src/lib.rs @@ -1,117 +1,44 @@ #![feature(decl_macro, proc_macro_hygiene, let_else)] mod api; -mod helpers; -use anyhow::{Context, Result}; -use poem::endpoint::{EmbeddedFileEndpoint, EmbeddedFilesEndpoint}; -use poem::listener::{Listener, RustlsCertificate, RustlsConfig, TcpListener}; -use poem::middleware::{AddData, SetHeader}; -use poem::session::{CookieConfig, MemoryStorage, ServerSession}; -use poem::{EndpointExt, Route, Server}; +use poem::{EndpointExt, IntoEndpoint, Route}; use poem_openapi::OpenApiService; -use rust_embed::RustEmbed; -use std::net::SocketAddr; -use tracing::*; use warpgate_common::Services; -#[derive(RustEmbed)] -#[folder = "../warpgate-admin/app/dist"] -pub struct Assets; +pub fn admin_api_app(services: &Services) -> impl IntoEndpoint { + let api_service = OpenApiService::new( + crate::api::get(), + "Warpgate Web Admin", + env!("CARGO_PKG_VERSION"), + ) + .server("/@warpgate/admin/api"); -pub struct AdminServer { - services: Services, -} + let ui = api_service.swagger_ui(); + let spec = api_service.spec_endpoint(); + let db = services.db.clone(); + let config = services.config.clone(); + let config_provider = services.config_provider.clone(); + let recordings = services.recordings.clone(); + let state = services.state.clone(); -impl AdminServer { - pub fn new(services: &Services) -> Self { - AdminServer { - services: services.clone(), - } - } - - pub async fn run(self, address: SocketAddr) -> Result<()> { - let state = self.services.state.clone(); - let api_service = OpenApiService::new( - ( - crate::api::sessions_list::Api, - crate::api::sessions_detail::Api, - crate::api::recordings_detail::Api, - crate::api::users_list::Api, - crate::api::targets_list::Api, - crate::api::tickets_list::Api, - crate::api::tickets_detail::Api, - crate::api::known_hosts_list::Api, - crate::api::known_hosts_detail::Api, - crate::api::info::Api, - crate::api::auth::Api, - crate::api::ssh_keys::Api, - crate::api::logs::Api, - ), - "Warpgate", - env!("CARGO_PKG_VERSION"), + Route::new() + .nest("", api_service) + .nest("/swagger", ui) + .nest("/openapi.json", spec) + .at( + "/recordings/:id/cast", + crate::api::recordings_detail::api_get_recording_cast, ) - .server("/api"); - let ui = api_service.swagger_ui(); - let spec = api_service.spec_endpoint(); - let db = self.services.db.clone(); - let config = self.services.config.clone(); - let config_provider = self.services.config_provider.clone(); - let recordings = self.services.recordings.clone(); - - let app = Route::new() - .nest("/api/swagger", ui) - .nest("/api", api_service) - .nest("/api/openapi.json", spec) - .nest_no_strip("/assets", EmbeddedFilesEndpoint::::new()) - .at("/", EmbeddedFileEndpoint::::new("index.html")) - .at( - "/api/recordings/:id/cast", - crate::api::recordings_detail::api_get_recording_cast, - ) - .at( - "/api/recordings/:id/stream", - crate::api::recordings_detail::api_get_recording_stream, - ) - .at( - "/api/recordings/:id/tcpdump", - crate::api::recordings_detail::api_get_recording_tcpdump, - ) - .with(ServerSession::new( - CookieConfig::default().secure(false), - MemoryStorage::default(), - )) - .with(SetHeader::new().overriding("Strict-Transport-Security", "max-age=31536000")) - .with(AddData::new(db)) - .with(AddData::new(config_provider)) - .with(AddData::new(state)) - .with(AddData::new(recordings)) - .with(AddData::new(config.clone())); - - let (certificate, key) = { - let config = config.lock().await; - let certificate_path = config - .paths_relative_to - .join(&config.store.web_admin.certificate); - let key_path = config.paths_relative_to.join(&config.store.web_admin.key); - - ( - std::fs::read(&certificate_path).with_context(|| { - format!( - "reading SSL certificate from '{}'", - certificate_path.display() - ) - })?, - std::fs::read(&key_path).with_context(|| { - format!("reading SSL private key from '{}'", key_path.display()) - })?, - ) - }; - - info!(?address, "Listening"); - Server::new(TcpListener::bind(address).rustls( - RustlsConfig::new().fallback(RustlsCertificate::new().cert(certificate).key(key)), - )) - .run(app) - .await - .context("Failed to start admin server") - } + .at( + "/recordings/:id/stream", + crate::api::recordings_detail::api_get_recording_stream, + ) + .at( + "/recordings/:id/tcpdump", + crate::api::recordings_detail::api_get_recording_tcpdump, + ) + .data(db) + .data(config_provider) + .data(state) + .data(recordings) + .data(config) } diff --git a/warpgate-admin/src/main.rs b/warpgate-admin/src/main.rs new file mode 100644 index 0000000..565eb72 --- /dev/null +++ b/warpgate-admin/src/main.rs @@ -0,0 +1,10 @@ +#![feature(type_alias_impl_trait, let_else, try_blocks)] +mod api; +use poem_openapi::OpenApiService; + +pub fn main() { + let api_service = + OpenApiService::new(api::get(), "Warpgate Web Admin", env!("CARGO_PKG_VERSION")) + .server("/@warpgate/admin/api"); + println!("{}", api_service.spec()); +} diff --git a/warpgate-common/src/config.rs b/warpgate-common/src/config.rs index 83738d8..756900d 100644 --- a/warpgate-common/src/config.rs +++ b/warpgate-common/src/config.rs @@ -1,5 +1,6 @@ -use poem_openapi::Object; +use poem_openapi::{Object, Union}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::path::PathBuf; use std::time::Duration; @@ -22,6 +23,10 @@ fn _default_username() -> String { "root".to_owned() } +fn _default_empty_string() -> String { + "".to_owned() +} + fn _default_recordings_path() -> String { "./data/recordings".to_owned() } @@ -30,7 +35,7 @@ fn _default_database_url() -> Secret { Secret::new("sqlite:data/db".to_owned()) } -fn _default_web_admin_listen() -> String { +fn _default_http_listen() -> String { "0.0.0.0:8888".to_owned() } @@ -69,6 +74,15 @@ impl Default for SSHTargetAuth { } } +#[derive(Debug, Deserialize, Serialize, Clone, Object)] +pub struct TargetHTTPOptions { + #[serde(default = "_default_empty_string")] + pub url: String, + + #[serde(default)] + pub headers: Option>, +} + #[derive(Debug, Deserialize, Serialize, Clone, Object, Default)] pub struct TargetWebAdminOptions {} @@ -77,10 +91,19 @@ pub struct Target { pub name: String, #[serde(default = "_default_empty_string_vec")] pub allow_roles: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub ssh: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub web_admin: Option, + #[serde(flatten)] + pub options: TargetOptions, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Union)] +#[oai(discriminator_name = "kind")] +pub enum TargetOptions { + #[serde(rename = "ssh")] + Ssh(TargetSSHOptions), + #[serde(rename = "http")] + Http(TargetHTTPOptions), + #[serde(rename = "web_admin")] + WebAdmin(TargetWebAdminOptions), } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] @@ -146,11 +169,11 @@ impl Default for SSHConfig { } #[derive(Debug, Deserialize, Serialize, Clone)] -pub struct WebAdminConfig { +pub struct HTTPConfig { #[serde(default = "_default_false")] pub enable: bool, - #[serde(default = "_default_web_admin_listen")] + #[serde(default = "_default_http_listen")] pub listen: String, #[serde(default)] @@ -160,11 +183,11 @@ pub struct WebAdminConfig { pub key: String, } -impl Default for WebAdminConfig { +impl Default for HTTPConfig { fn default() -> Self { - WebAdminConfig { + HTTPConfig { enable: true, - listen: _default_web_admin_listen(), + listen: _default_http_listen(), certificate: "".to_owned(), key: "".to_owned(), } @@ -216,15 +239,15 @@ pub struct WarpgateConfigStore { #[serde(default)] pub recordings: RecordingsConfig, - #[serde(default)] - pub web_admin: WebAdminConfig, - #[serde(default = "_default_database_url")] pub database_url: Secret, #[serde(default)] pub ssh: SSHConfig, + #[serde(default)] + pub http: HTTPConfig, + #[serde(default)] pub log: LogConfig, } @@ -236,9 +259,9 @@ impl Default for WarpgateConfigStore { users: vec![], roles: vec![], recordings: RecordingsConfig::default(), - web_admin: WebAdminConfig::default(), database_url: _default_database_url(), ssh: SSHConfig::default(), + http: HTTPConfig::default(), log: LogConfig::default(), } } diff --git a/warpgate-common/src/config_providers/file.rs b/warpgate-common/src/config_providers/file.rs index 2ae4e30..3a7654d 100644 --- a/warpgate-common/src/config_providers/file.rs +++ b/warpgate-common/src/config_providers/file.rs @@ -107,52 +107,47 @@ impl ConfigProvider for FileConfigProvider { let client_key = format!("{} {}", kind, base64_bytes); debug!(username = &user.username[..], "Client key: {}", client_key); - for credential in user.credentials.iter() { - if let UserAuthCredential::PublicKey { key: ref user_key } = credential { - if &client_key == user_key.expose_secret() { - valid_credentials.push(credential); - break; + if let Some(credential) = + user.credentials.iter().find(|credential| match credential { + UserAuthCredential::PublicKey { key: ref user_key } => { + &client_key == user_key.expose_secret() } - } + _ => false, + }) + { + valid_credentials.push(credential) } } AuthCredential::Password(client_password) => { - for credential in user.credentials.iter() { - if let UserAuthCredential::Password { + match user.credentials.iter().find(|credential| match credential { + UserAuthCredential::Password { hash: ref user_password_hash, - } = credential - { - match verify_password_hash( - client_password.expose_secret(), - user_password_hash.expose_secret(), - ) { - Ok(true) => { - valid_credentials.push(credential); - break; - } - Ok(false) => continue, - Err(e) => { - error!( - username = &user.username[..], - "Error verifying password hash: {}", e - ); - continue; - } - } - } + } => verify_password_hash( + client_password.expose_secret(), + user_password_hash.expose_secret(), + ) + .unwrap_or_else(|e| { + error!( + username = &user.username[..], + "Error verifying password hash: {}", e + ); + false + }), + _ => false, + }) { + Some(credential) => valid_credentials.push(credential), + None => return Ok(AuthResult::Rejected), } } - AuthCredential::OTP(client_otp) => { - for credential in user.credentials.iter() { - if let UserAuthCredential::TOTP { + AuthCredential::Otp(client_otp) => { + match user.credentials.iter().find(|credential| match credential { + UserAuthCredential::TOTP { key: ref user_otp_key, - } = credential - { - if verify_totp(client_otp.expose_secret(), user_otp_key) { - valid_credentials.push(credential); - break; - } - } + } => verify_totp(client_otp.expose_secret(), user_otp_key), + _ => false, + }) { + Some(credential) => valid_credentials.push(credential), + None => return Ok(AuthResult::Rejected), } } } @@ -182,7 +177,7 @@ impl ConfigProvider for FileConfigProvider { username: user.username.clone(), }); } else if remaining_required_kinds.contains(&"otp".to_string()) { - return Ok(AuthResult::OTPNeeded); + return Ok(AuthResult::OtpNeeded); } else { return Ok(AuthResult::Rejected); } diff --git a/warpgate-common/src/config_providers/mod.rs b/warpgate-common/src/config_providers/mod.rs index 726fa16..112d59c 100644 --- a/warpgate-common/src/config_providers/mod.rs +++ b/warpgate-common/src/config_providers/mod.rs @@ -13,12 +13,12 @@ use warpgate_db_entities::Ticket; pub enum AuthResult { Accepted { username: String }, - OTPNeeded, + OtpNeeded, Rejected, } pub enum AuthCredential { - OTP(Secret), + Otp(Secret), Password(Secret), PublicKey { kind: String, diff --git a/warpgate-common/src/data.rs b/warpgate-common/src/data.rs index 7168ab4..d3baf9b 100644 --- a/warpgate-common/src/data.rs +++ b/warpgate-common/src/data.rs @@ -14,6 +14,7 @@ pub struct SessionSnapshot { pub started: DateTime, pub ended: Option>, pub ticket_id: Option, + pub protocol: String, } impl From for SessionSnapshot { @@ -27,6 +28,7 @@ impl From for SessionSnapshot { started: model.started, ended: model.ended, ticket_id: model.ticket_id, + protocol: model.protocol, } } } diff --git a/warpgate-common/src/lib.rs b/warpgate-common/src/lib.rs index caefaa8..b5180b5 100644 --- a/warpgate-common/src/lib.rs +++ b/warpgate-common/src/lib.rs @@ -12,6 +12,7 @@ mod protocols; pub mod recordings; mod services; mod state; +mod try_macro; mod types; pub use config::*; @@ -20,4 +21,5 @@ pub use data::*; pub use protocols::*; pub use services::*; pub use state::{SessionState, State}; +pub use try_macro::*; pub use types::*; diff --git a/warpgate-common/src/protocols/handle.rs b/warpgate-common/src/protocols/handle.rs index fbc717c..df20f51 100644 --- a/warpgate-common/src/protocols/handle.rs +++ b/warpgate-common/src/protocols/handle.rs @@ -35,6 +35,10 @@ impl WarpgateServerHandle { self.id } + pub fn session_state(&self) -> &Arc> { + &self.session_state + } + pub async fn set_username(&mut self, username: String) -> Result<()> { use sea_orm::ActiveValue::Set; @@ -56,7 +60,7 @@ impl WarpgateServerHandle { Ok(()) } - pub async fn set_target(&mut self, target: &Target) -> Result<()> { + pub async fn set_target(&self, target: &Target) -> Result<()> { use sea_orm::ActiveValue::Set; { self.session_state.lock().await.target = Some(target.clone()); diff --git a/warpgate-common/src/recordings/writer.rs b/warpgate-common/src/recordings/writer.rs index 377edf4..f355339 100644 --- a/warpgate-common/src/recordings/writer.rs +++ b/warpgate-common/src/recordings/writer.rs @@ -1,4 +1,5 @@ use crate::helpers::fs::secure_file; +use crate::try_block; use super::{Error, Result}; use bytes::{Bytes, BytesMut}; @@ -51,7 +52,7 @@ impl RecordingWriter { }); tokio::spawn(async move { - if let Err(error) = async { + try_block!(async { let mut last_flush = Instant::now(); loop { if Instant::now() - last_flush > Duration::from_secs(5) { @@ -69,13 +70,11 @@ impl RecordingWriter { } } Ok::<(), anyhow::Error>(()) - } - .await - { + } catch (error: anyhow::Error) { error!(%error, ?path, "Failed to write recording"); - } + }); - if let Err(error) = async { + try_block!(async { writer.flush().await?; use sea_orm::ActiveValue::Set; @@ -89,11 +88,9 @@ impl RecordingWriter { model.ended = Set(Some(chrono::Utc::now())); model.update(&*db).await?; Ok::<(), anyhow::Error>(()) - } - .await - { + } catch (error: anyhow::Error) { error!(%error, ?path, "Failed to write recording"); - } + }); }); Ok(RecordingWriter { diff --git a/warpgate-common/src/state.rs b/warpgate-common/src/state.rs index 8417007..4f671bc 100644 --- a/warpgate-common/src/state.rs +++ b/warpgate-common/src/state.rs @@ -1,4 +1,4 @@ -use crate::{SessionHandle, SessionId, Target, WarpgateServerHandle}; +use crate::{ProtocolName, SessionHandle, SessionId, Target, WarpgateServerHandle}; use anyhow::{Context, Result}; use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait}; use std::collections::HashMap; @@ -28,8 +28,9 @@ impl State { pub async fn register_session( &mut self, + protocol: &ProtocolName, session: &Arc>, - ) -> Result { + ) -> Result>> { let id = uuid::Uuid::new_v4(); self.sessions.insert(id, session.clone()); @@ -39,7 +40,13 @@ impl State { let values = Session::ActiveModel { id: Set(id), started: Set(chrono::Utc::now()), - remote_address: Set(session.lock().await.remote_address.to_string()), + remote_address: Set(session + .lock() + .await + .remote_address + .map(|x| x.to_string()) + .unwrap_or_else(|| "".to_string())), + protocol: Set(protocol.to_string()), ..Default::default() }; @@ -51,12 +58,12 @@ impl State { } match self.this.upgrade() { - Some(this) => Ok(WarpgateServerHandle::new( + Some(this) => Ok(Arc::new(Mutex::new(WarpgateServerHandle::new( id, self.db.clone(), this, session.clone(), - )), + )))), None => anyhow::bail!("State is being detroyed"), } } @@ -84,14 +91,14 @@ impl State { } pub struct SessionState { - pub remote_address: SocketAddr, + pub remote_address: Option, pub username: Option, pub target: Option, pub handle: Box, } impl SessionState { - pub fn new(remote_address: SocketAddr, handle: Box) -> Self { + pub fn new(remote_address: Option, handle: Box) -> Self { SessionState { remote_address, username: None, diff --git a/warpgate-common/src/try_macro.rs b/warpgate-common/src/try_macro.rs new file mode 100644 index 0000000..3b65aad --- /dev/null +++ b/warpgate-common/src/try_macro.rs @@ -0,0 +1,48 @@ +#[macro_export] +macro_rules! try_block { + ($try:block catch ($err:ident : $errtype:ty) $catch:block) => {{ + #[allow(unreachable_code)] + let result: anyhow::Result<_, $errtype> = (|| Ok::<_, $errtype>($try))(); + match result { + Ok(_) => (), + Err($err) => { + { + $catch + }; + } + }; + }}; + (async $try:block catch ($err:ident : $errtype:ty) $catch:block) => {{ + let result: anyhow::Result<_, $errtype> = (async { Ok::<_, $errtype>($try) }).await; + match result { + Ok(_) => (), + Err($err) => { + { + $catch + }; + } + }; + }}; +} + +#[test] +fn test_catch() { + let mut caught = false; + try_block!({ + let _: u32 = "asdf".parse()?; + panic!(); + } catch (e: anyhow::Error) { + assert_eq!(e.to_string(), "asdf".parse::().unwrap_err().to_string()); + caught = true; + }); + assert!(caught); +} + +#[test] +fn test_success() { + try_block!({ + let _: u32 = "123".parse()?; + } catch (_e: anyhow::Error) { + panic!(); + }); +} diff --git a/warpgate-common/src/types.rs b/warpgate-common/src/types.rs index 6a7971f..7910b63 100644 --- a/warpgate-common/src/types.rs +++ b/warpgate-common/src/types.rs @@ -1,12 +1,24 @@ +use bytes::Bytes; +use data_encoding::HEXLOWER; +use rand::Rng; use serde::{Deserialize, Serialize}; use std::fmt::Debug; use uuid::Uuid; +use crate::helpers::rng::get_crypto_rng; + pub type SessionId = Uuid; +pub type ProtocolName = &'static str; #[derive(PartialEq, Clone)] pub struct Secret(T); +impl Secret { + pub fn random() -> Self { + Secret::new(HEXLOWER.encode(&Bytes::from_iter(get_crypto_rng().gen::<[u8; 32]>()))) + } +} + impl Secret { pub const fn new(v: T) -> Self { Self(v) @@ -17,6 +29,12 @@ impl Secret { } } +impl From for Secret { + fn from(v: T) -> Self { + Self::new(v) + } +} + impl<'de, T> Deserialize<'de> for Secret where T: Deserialize<'de>, diff --git a/warpgate-db-entities/src/Session.rs b/warpgate-db-entities/src/Session.rs index 16f33be..2983508 100644 --- a/warpgate-db-entities/src/Session.rs +++ b/warpgate-db-entities/src/Session.rs @@ -13,6 +13,7 @@ pub struct Model { pub started: DateTime, pub ended: Option>, pub ticket_id: Option, + pub protocol: String, } #[derive(Copy, Clone, Debug, EnumIter)] diff --git a/warpgate-db-migrations/src/lib.rs b/warpgate-db-migrations/src/lib.rs index 48de26e..b5a2904 100644 --- a/warpgate-db-migrations/src/lib.rs +++ b/warpgate-db-migrations/src/lib.rs @@ -7,6 +7,7 @@ mod m00002_create_session; mod m00003_create_recording; mod m00004_create_known_host; mod m00005_create_log_entry; +mod m00006_add_session_protocol; pub struct Migrator; @@ -19,6 +20,7 @@ impl MigratorTrait for Migrator { Box::new(m00003_create_recording::Migration), Box::new(m00004_create_known_host::Migration), Box::new(m00005_create_log_entry::Migration), + Box::new(m00006_add_session_protocol::Migration), ] } } diff --git a/warpgate-db-migrations/src/m00006_add_session_protocol.rs b/warpgate-db-migrations/src/m00006_add_session_protocol.rs new file mode 100644 index 0000000..ee36b1d --- /dev/null +++ b/warpgate-db-migrations/src/m00006_add_session_protocol.rs @@ -0,0 +1,41 @@ +use sea_orm_migration::prelude::*; + +pub struct Migration; + +impl MigrationName for Migration { + fn name(&self) -> &str { + "m00006_add_session_protocol" + } +} + +use crate::m00002_create_session::session; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(session::Entity) + .add_column( + ColumnDef::new(Alias::new("protocol")) + .string() + .not_null() + .default("SSH"), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(session::Entity) + .drop_column(Alias::new("protocol")) + .to_owned(), + ) + .await + } +} diff --git a/warpgate-protocol-http/Cargo.toml b/warpgate-protocol-http/Cargo.toml new file mode 100644 index 0000000..ae4c977 --- /dev/null +++ b/warpgate-protocol-http/Cargo.toml @@ -0,0 +1,29 @@ +[package] +edition = "2021" +license = "Apache-2.0" +name = "warpgate-protocol-http" +version = "0.2.5" + +[dependencies] +anyhow = "1.0" +async-trait = "0.1" +cookie = "0.16" +data-encoding = "2.3" +delegate = "0.6" +futures = "0.3" +http = "0.2" +lazy_static = "1.4" +poem = {version = "^1.3.30", features = ["cookie", "session", "anyhow", "rustls", "websocket", "sse", "embed"]} +poem-openapi = {version = "^1.3.30", features = ["swagger-ui"]} +reqwest = {version = "0.11", features = ["rustls-tls-native-roots", "stream"]} +serde = "1.0" +serde_json = "1.0" +tokio = {version = "1.18", features = ["tracing", "signal"]} +tokio-tungstenite = {version = "0.17", features = ["rustls-tls-native-roots"]} +tracing = "0.1" +warpgate-admin = {version = "*", path = "../warpgate-admin"} +warpgate-common = {version = "*", path = "../warpgate-common"} +warpgate-db-entities = {version = "*", path = "../warpgate-db-entities"} +warpgate-web = {version = "*", path = "../warpgate-web"} +percent-encoding = "2.1" +uuid = {version = "0.8", features = ["v4"]} diff --git a/warpgate-protocol-http/src/api/auth.rs b/warpgate-protocol-http/src/api/auth.rs new file mode 100644 index 0000000..30b8e7a --- /dev/null +++ b/warpgate-protocol-http/src/api/auth.rs @@ -0,0 +1,112 @@ +use crate::common::SessionExt; +use crate::session::SessionMiddleware; +use poem::session::Session; +use poem::web::Data; +use poem::Request; +use poem_openapi::payload::Json; +use poem_openapi::{ApiResponse, Enum, Object, OpenApi}; +use std::sync::Arc; +use tokio::sync::Mutex; +use tracing::*; +use warpgate_common::{AuthCredential, AuthResult, Secret, Services}; + +pub struct Api; + +#[derive(Object)] +struct LoginRequest { + username: String, + password: String, + otp: Option, +} + +#[derive(Enum)] +enum LoginFailureReason { + InvalidCredentials, + OtpNeeded, +} + +#[derive(Object)] +struct LoginFailureResponse { + reason: LoginFailureReason, +} + +#[derive(ApiResponse)] +enum LoginResponse { + #[oai(status = 201)] + Success, + + #[oai(status = 401)] + Failure(Json), +} + +#[derive(ApiResponse)] +enum LogoutResponse { + #[oai(status = 201)] + Success, +} + +#[OpenApi] +impl Api { + #[oai(path = "/auth/login", method = "post", operation_id = "login")] + async fn api_auth_login( + &self, + req: &Request, + session: &Session, + services: Data<&Services>, + session_middleware: Data<&Arc>>, + body: Json, + ) -> poem::Result { + let mut credentials = vec![AuthCredential::Password(Secret::new(body.password.clone()))]; + if let Some(ref otp) = body.otp { + credentials.push(AuthCredential::Otp(otp.clone().into())); + } + + let result = { + let mut config_provider = services.config_provider.lock().await; + config_provider + .authorize(&body.username, &credentials) + .await + .map_err(|e| e.context("Failed to authorize user"))? + }; + + match result { + AuthResult::Accepted { username } => { + let server_handle = session_middleware + .lock() + .await + .create_handle_for(&req) + .await?; + server_handle + .lock() + .await + .set_username(username.clone()) + .await?; + info!(%username, "Authenticated"); + session.set_username(username); + Ok(LoginResponse::Success) + } + x => { + error!("Auth rejected"); + Ok(LoginResponse::Failure(Json(LoginFailureResponse { + reason: match x { + AuthResult::Accepted { .. } => unreachable!(), + AuthResult::Rejected => LoginFailureReason::InvalidCredentials, + AuthResult::OtpNeeded => LoginFailureReason::OtpNeeded, + }, + }))) + } + } + } + + #[oai(path = "/auth/logout", method = "post", operation_id = "logout")] + async fn api_auth_logout( + &self, + session: &Session, + session_middleware: Data<&Arc>>, + ) -> poem::Result { + session_middleware.lock().await.remove_session(session); + session.clear(); + info!("Logged out"); + Ok(LogoutResponse::Success) + } +} diff --git a/warpgate-protocol-http/src/api/info.rs b/warpgate-protocol-http/src/api/info.rs new file mode 100644 index 0000000..5706868 --- /dev/null +++ b/warpgate-protocol-http/src/api/info.rs @@ -0,0 +1,59 @@ +use std::net::ToSocketAddrs; + +use crate::common::SessionExt; +use poem::session::Session; +use poem::web::Data; +use poem_openapi::payload::Json; +use poem_openapi::{ApiResponse, Object, OpenApi}; +use serde::Serialize; +use warpgate_common::Services; + +pub struct Api; + +#[derive(Serialize, Object)] +pub struct PortsInfo { + ssh: u16, +} + +#[derive(Serialize, Object)] +pub struct Info { + version: String, + username: Option, + selected_target: Option, + ports: PortsInfo, +} + +#[derive(ApiResponse)] +enum InstanceInfoResponse { + #[oai(status = 200)] + Ok(Json), +} + +#[OpenApi] +impl Api { + #[oai(path = "/info", method = "get", operation_id = "get_info")] + async fn api_get_info( + &self, + session: &Session, + services: Data<&Services>, + ) -> poem::Result { + let config = services.config.lock().await; + Ok(InstanceInfoResponse::Ok(Json(Info { + version: env!("CARGO_PKG_VERSION").to_string(), + username: session.get_username(), + selected_target: session.get_target_name(), + ports: if session.is_authenticated() { + PortsInfo { + ssh: config + .store + .ssh + .listen + .to_socket_addrs() + .map_or(0, |mut x| x.next().map(|x| x.port()).unwrap_or(0)), + } + } else { + PortsInfo { ssh: 0 } + }, + }))) + } +} diff --git a/warpgate-protocol-http/src/api/mod.rs b/warpgate-protocol-http/src/api/mod.rs new file mode 100644 index 0000000..ea5fd0e --- /dev/null +++ b/warpgate-protocol-http/src/api/mod.rs @@ -0,0 +1,9 @@ +use poem_openapi::OpenApi; + +pub mod auth; +pub mod info; +pub mod targets_list; + +pub fn get() -> impl OpenApi { + (auth::Api, info::Api, targets_list::Api) +} diff --git a/warpgate-protocol-http/src/api/targets_list.rs b/warpgate-protocol-http/src/api/targets_list.rs new file mode 100644 index 0000000..9e69d25 --- /dev/null +++ b/warpgate-protocol-http/src/api/targets_list.rs @@ -0,0 +1,77 @@ +use crate::common::{endpoint_auth, SessionUsername}; +use futures::stream::{self}; +use futures::StreamExt; +use poem::web::Data; +use poem_openapi::payload::Json; +use poem_openapi::{ApiResponse, Enum, Object, OpenApi}; +use serde::Serialize; +use warpgate_common::{Services, TargetOptions}; + +pub struct Api; + +#[derive(Debug, Serialize, Clone, Enum)] +pub enum TargetKind { + Http, + Ssh, + WebAdmin, +} + +#[derive(Debug, Serialize, Clone, Object)] +pub struct Target { + pub name: String, + pub kind: TargetKind, +} + +#[derive(ApiResponse)] +enum GetTargetsResponse { + #[oai(status = 200)] + Ok(Json>), +} + +#[OpenApi] +impl Api { + #[oai( + path = "/targets", + method = "get", + operation_id = "get_targets", + transform = "endpoint_auth" + )] + async fn api_get_all_targets( + &self, + services: Data<&Services>, + username: Data<&SessionUsername>, + ) -> poem::Result { + let targets = { + let mut config_provider = services.config_provider.lock().await; + config_provider.list_targets().await? + }; + let mut targets = stream::iter(targets) + .filter_map(|t| { + let services = services.clone(); + let username = &username; + async move { + let mut config_provider = services.config_provider.lock().await; + match config_provider.authorize_target(&username.0.0, &t.name).await { + Ok(true) => Some(t), + _ => None, + } + } + }) + .collect::>() + .await; + targets.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(GetTargetsResponse::Ok(Json( + targets + .into_iter() + .map(|t| Target { + name: t.name.clone(), + kind: match t.options { + TargetOptions::Ssh(_) => TargetKind::Ssh, + TargetOptions::Http(_) => TargetKind::Http, + TargetOptions::WebAdmin(_) => TargetKind::WebAdmin, + }, + }) + .collect(), + ))) + } +} diff --git a/warpgate-protocol-http/src/catchall.rs b/warpgate-protocol-http/src/catchall.rs new file mode 100644 index 0000000..4eeb626 --- /dev/null +++ b/warpgate-protocol-http/src/catchall.rs @@ -0,0 +1,84 @@ +use crate::common::{gateway_redirect, SessionExt, SessionUsername}; +use crate::proxy::{proxy_normal_request, proxy_websocket_request}; +use poem::session::Session; +use poem::web::websocket::WebSocket; +use poem::web::Data; +use poem::{handler, Body, IntoResponse, Request, Response}; +use serde::Deserialize; +use std::sync::Arc; +use tokio::sync::Mutex; +use tracing::*; +use warpgate_common::{Services, TargetOptions, WarpgateServerHandle}; + +#[derive(Deserialize)] +struct QueryParams { + warpgate_target: Option, +} + +#[handler] +pub async fn catchall_endpoint( + req: &Request, + ws: Option, + session: &Session, + body: Body, + username: Data<&SessionUsername>, + services: Data<&Services>, + server_handle: Option>>>, +) -> poem::Result { + let params: QueryParams = req.params()?; + + if let Some(target_name) = params.warpgate_target { + session.set_target_name(target_name); + } + + let Some(target_name) = session.get_target_name() else { + return Ok(gateway_redirect(req).into_response()); + }; + + let target = { + services + .config + .lock() + .await + .store + .targets + .iter() + .filter_map(|t| match t.options { + TargetOptions::Http(ref options) => Some((t, options)), + _ => None, + }) + .find(|(t, _)| t.name == target_name) + .map(|(t, o)| (t.clone(), o.clone())) + }; + + let Some((target, options)) = target else { + return Ok(gateway_redirect(req).into_response()); + }; + + if !services + .config_provider + .lock() + .await + .authorize_target(&username.0 .0, &target.name) + .await? + { + return Ok(gateway_redirect(req).into_response()); + } + + if let Some(server_handle) = server_handle { + server_handle.lock().await.set_target(&target).await?; + } + + let span = info_span!("", target=%target.name); + + Ok(match ws { + Some(ws) => proxy_websocket_request(req, ws, &options) + .instrument(span) + .await? + .into_response(), + None => proxy_normal_request(req, body, &options) + .instrument(span) + .await? + .into_response(), + }) +} diff --git a/warpgate-protocol-http/src/common.rs b/warpgate-protocol-http/src/common.rs new file mode 100644 index 0000000..440c311 --- /dev/null +++ b/warpgate-protocol-http/src/common.rs @@ -0,0 +1,131 @@ +use std::time::Duration; + +use http::StatusCode; +use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; +use poem::session::Session; +use poem::web::{Data, Redirect}; +use poem::{Endpoint, EndpointExt, FromRequest, IntoResponse, Request, Response}; +use warpgate_common::{Services, TargetOptions}; + +static USERNAME_SESSION_KEY: &str = "username"; +static TARGET_SESSION_KEY: &str = "target_name"; +pub static SESSION_MAX_AGE: Duration = Duration::from_secs(60 * 30); + +pub trait SessionExt { + fn has_selected_target(&self) -> bool; + fn get_target_name(&self) -> Option; + fn set_target_name(&self, target_name: String); + fn is_authenticated(&self) -> bool; + fn get_username(&self) -> Option; + fn set_username(&self, username: String); +} + +impl SessionExt for Session { + fn has_selected_target(&self) -> bool { + self.get_target_name().is_some() + } + + fn get_target_name(&self) -> Option { + self.get::(TARGET_SESSION_KEY) + } + + fn set_target_name(&self, target_name: String) { + self.set(TARGET_SESSION_KEY, target_name); + } + + fn is_authenticated(&self) -> bool { + self.get_username().is_some() + } + + fn get_username(&self) -> Option { + self.get::(USERNAME_SESSION_KEY) + } + + fn set_username(&self, username: String) { + self.set(USERNAME_SESSION_KEY, username); + } +} + +#[derive(Clone)] +pub struct SessionUsername(pub String); + +async fn is_user_admin(req: &Request, username: &SessionUsername) -> poem::Result { + let services: Data<&Services> = <_>::from_request_without_body(&req).await?; + + let mut config_provider = services.config_provider.lock().await; + let targets = config_provider.list_targets().await?; + for target in targets { + if matches!(target.options, TargetOptions::WebAdmin(_)) + && config_provider + .authorize_target(&username.0, &target.name) + .await? + { + drop(config_provider); + return Ok(true); + } + } + Ok(false) +} + +pub fn endpoint_admin_auth(e: E) -> impl Endpoint { + e.around(|ep, req| async move { + let username: Data<&SessionUsername> = <_>::from_request_without_body(&req).await?; + if is_user_admin(&req, username.0).await? { + return Ok(ep.call(req).await?.into_response()); + } + Err(poem::Error::from_status(StatusCode::UNAUTHORIZED)) + }) +} + +pub fn page_admin_auth(e: E) -> impl Endpoint { + e.around(|ep, req| async move { + let username: Data<&SessionUsername> = <_>::from_request_without_body(&req).await?; + let session: &Session = <_>::from_request_without_body(&req).await?; + if is_user_admin(&req, username.0).await? { + return Ok(ep.call(req).await?.into_response()); + } + session.clear(); + Ok(gateway_redirect(&req).into_response()) + }) +} + +pub fn endpoint_auth(e: E) -> impl Endpoint { + e.around(|ep, req| async move { + let session: &Session = FromRequest::from_request_without_body(&req).await?; + + match session.get_username() { + Some(username) => Ok(ep.data(SessionUsername(username)).call(req).await?), + None => Err(poem::Error::from_status(StatusCode::UNAUTHORIZED)), + } + }) +} + +pub fn page_auth(e: E) -> impl Endpoint { + e.around(|ep, req| async move { + let session: &Session = FromRequest::from_request_without_body(&req).await?; + + match session.get_username() { + Some(username) => Ok(ep + .data(SessionUsername(username)) + .call(req) + .await? + .into_response()), + None => Ok(gateway_redirect(&req).into_response()), + } + }) +} + +pub fn gateway_redirect(req: &Request) -> Response { + let path = req + .original_uri() + .path_and_query() + .map(|p| p.to_string()) + .unwrap_or("".into()); + + let path = format!( + "/@warpgate?next={}", + utf8_percent_encode(&path, NON_ALPHANUMERIC), + ); + + Redirect::temporary(path).into_response() +} diff --git a/warpgate-protocol-http/src/lib.rs b/warpgate-protocol-http/src/lib.rs new file mode 100644 index 0000000..b533e1b --- /dev/null +++ b/warpgate-protocol-http/src/lib.rs @@ -0,0 +1,166 @@ +#![feature(type_alias_impl_trait, let_else, try_blocks)] +mod api; +mod catchall; +mod common; +mod logging; +mod proxy; +mod session; +mod session_handle; +use crate::common::{endpoint_admin_auth, endpoint_auth, page_auth, SESSION_MAX_AGE}; +use crate::session::{SessionMiddleware, SharedSessionStorage}; +use anyhow::{Context, Result}; +use async_trait::async_trait; +use common::page_admin_auth; +use logging::{log_request_result, span_for_request}; +use poem::endpoint::{EmbeddedFileEndpoint, EmbeddedFilesEndpoint}; +use poem::listener::{Listener, RustlsCertificate, RustlsConfig, TcpListener}; +use poem::middleware::SetHeader; +use poem::session::{CookieConfig, MemoryStorage, ServerSession}; +use poem::web::Data; +use poem::{Endpoint, EndpointExt, FromRequest, IntoEndpoint, Route, Server}; +use poem_openapi::OpenApiService; +use std::fmt::Debug; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Mutex; +use tracing::*; +use warpgate_admin::admin_api_app; +use warpgate_common::{ProtocolName, ProtocolServer, Services, Target, TargetTestError}; +use warpgate_web::Assets; + +pub const PROTOCOL_NAME: ProtocolName = "HTTP"; + +pub struct HTTPProtocolServer { + services: Services, +} + +impl HTTPProtocolServer { + pub async fn new(services: &Services) -> Result { + Ok(HTTPProtocolServer { + services: services.clone(), + }) + } +} + +#[async_trait] +impl ProtocolServer for HTTPProtocolServer { + async fn run(self, address: SocketAddr) -> Result<()> { + let admin_api_app = admin_api_app(&self.services).into_endpoint(); + let api_service = OpenApiService::new( + crate::api::get(), + "Warpgate HTTP proxy", + env!("CARGO_PKG_VERSION"), + ) + .server("/@warpgate/api"); + let ui = api_service.swagger_ui(); + let spec = api_service.spec_endpoint(); + + let session_storage = + SharedSessionStorage(Arc::new(Mutex::new(Box::new(MemoryStorage::default())))); + let session_middleware = SessionMiddleware::new(); + + let app = Route::new() + .nest( + "/@warpgate", + Route::new() + .nest("/api/swagger", ui) + .nest("/api", api_service) + .nest("/api/openapi.json", spec) + .nest_no_strip("/assets", EmbeddedFilesEndpoint::::new()) + .nest( + "/admin/api", + endpoint_auth(endpoint_admin_auth(admin_api_app)), + ) + .at( + "/admin", + page_auth(page_admin_auth(EmbeddedFileEndpoint::::new( + "src/admin/index.html", + ))), + ) + .at( + "", + EmbeddedFileEndpoint::::new("src/gateway/index.html"), + ) + .around(move |ep, req| async move { + let method = req.method().clone(); + let url = req.original_uri().clone(); + let response = ep.call(req).await?; + log_request_result(&method, &url, &response.status()); + Ok(response) + }), + ) + .nest_no_strip("/", page_auth(catchall::catchall_endpoint)) + .around(move |ep, req| async move { + let sm = Data::<&Arc>>::from_request_without_body(&req) + .await? + .clone(); + + let req = { sm.lock().await.process_request(req).await? }; + + let span = span_for_request(&req).await?; + + ep.call(req).instrument(span).await + }) + .with( + SetHeader::new() + .overriding(http::header::STRICT_TRANSPORT_SECURITY, "max-age=31536000"), + ) + .with(ServerSession::new( + CookieConfig::default() + .secure(false) + .max_age(SESSION_MAX_AGE) + .name("warpgate-http-session"), + session_storage.clone(), + )) + .data(self.services.clone()) + .data(session_middleware.clone()) + .data(session_storage); + + tokio::spawn(async move { + loop { + session_middleware.lock().await.vacuum().await; + tokio::time::sleep(Duration::from_secs(60)).await; + } + }); + + let (certificate, key) = { + let config = self.services.config.lock().await; + let certificate_path = config + .paths_relative_to + .join(&config.store.http.certificate); + let key_path = config.paths_relative_to.join(&config.store.http.key); + + ( + std::fs::read(&certificate_path).with_context(|| { + format!( + "reading SSL certificate from '{}'", + certificate_path.display() + ) + })?, + std::fs::read(&key_path).with_context(|| { + format!("reading SSL private key from '{}'", key_path.display()) + })?, + ) + }; + + info!(?address, "Listening"); + Server::new(TcpListener::bind(address).rustls( + RustlsConfig::new().fallback(RustlsCertificate::new().cert(certificate).key(key)), + )) + .run(app) + .await?; + + Ok(()) + } + + async fn test_target(self, _target: Target) -> Result<(), TargetTestError> { + Ok(()) + } +} + +impl Debug for HTTPProtocolServer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "SSHProtocolServer") + } +} diff --git a/warpgate-protocol-http/src/logging.rs b/warpgate-protocol-http/src/logging.rs new file mode 100644 index 0000000..4a8ff2d --- /dev/null +++ b/warpgate-protocol-http/src/logging.rs @@ -0,0 +1,32 @@ +use http::{Method, StatusCode, Uri}; +use poem::{FromRequest, Request}; +use tracing::*; + +use crate::session_handle::WarpgateServerHandleFromRequest; + +pub async fn span_for_request(req: &Request) -> poem::Result { + let handle = WarpgateServerHandleFromRequest::from_request_without_body(req) + .await; + + Ok(match handle { + Ok(ref handle) => { + let handle = handle.lock().await; + let ss = handle.session_state().lock().await; + match { ss.username.clone() } { + Some(ref username) => { + info_span!("HTTP", session=%handle.id(), session_username=%username) + } + None => info_span!("HTTP", session=%handle.id()), + } + } + Err(_) => info_span!("HTTP"), + }) +} + +pub fn log_request_result(method: &Method, url: &Uri, status: &StatusCode) { + if status.is_server_error() || status.is_client_error() { + warn!(%method, %url, %status, "Request failed"); + } else { + info!(%method, %url, %status, "Request"); + } +} diff --git a/warpgate-protocol-http/src/main.rs b/warpgate-protocol-http/src/main.rs new file mode 100644 index 0000000..1d14b7b --- /dev/null +++ b/warpgate-protocol-http/src/main.rs @@ -0,0 +1,16 @@ +#![feature(type_alias_impl_trait, let_else, try_blocks)] +mod api; +mod common; +mod session; +mod session_handle; +use poem_openapi::OpenApiService; + +pub fn main() { + let api_service = OpenApiService::new( + api::get(), + "Warpgate HTTP proxy", + env!("CARGO_PKG_VERSION"), + ) + .server("/@warpgate/api"); + println!("{}", api_service.spec()); +} diff --git a/warpgate-protocol-http/src/proxy.rs b/warpgate-protocol-http/src/proxy.rs new file mode 100644 index 0000000..0702ed1 --- /dev/null +++ b/warpgate-protocol-http/src/proxy.rs @@ -0,0 +1,402 @@ +use anyhow::Result; +use cookie::Cookie; +use delegate::delegate; +use futures::{SinkExt, StreamExt}; +use http::header::HeaderName; +use http::uri::{Authority, Scheme}; +use http::Uri; +use poem::web::websocket::{CloseCode, Message, WebSocket}; +use poem::{Body, IntoResponse, Request, Response}; +use std::borrow::Cow; +use std::collections::HashSet; +use std::str::FromStr; +use tokio_tungstenite::{connect_async_with_config, tungstenite}; +use tracing::*; +use warpgate_common::{try_block, TargetHTTPOptions}; +use warpgate_web::lookup_built_file; + +use crate::logging::log_request_result; + +trait SomeResponse { + fn status(&self) -> http::StatusCode; + fn headers(&self) -> &http::HeaderMap; +} + +impl SomeResponse for reqwest::Response { + delegate! { + to self { + fn status(&self) -> http::StatusCode; + fn headers(&self) -> &http::HeaderMap; + } + } +} + +impl SomeResponse for http::Response { + delegate! { + to self { + fn status(&self) -> http::StatusCode; + fn headers(&self) -> &http::HeaderMap; + } + } +} + +trait SomeRequestBuilder { + fn header(self, k: HeaderName, v: String) -> Self; +} + +impl SomeRequestBuilder for reqwest::RequestBuilder { + delegate! { + to self { + fn header(self, k: HeaderName, v: String) -> Self; + } + } +} + +impl SomeRequestBuilder for http::request::Builder { + delegate! { + to self { + fn header(self, k: HeaderName, v: String) -> Self; + } + } +} + +lazy_static::lazy_static! { + static ref DONT_FORWARD_HEADERS: HashSet = { + let mut s = HashSet::new(); + s.insert(http::header::ACCEPT_ENCODING); + s.insert(http::header::SEC_WEBSOCKET_EXTENSIONS); + s.insert(http::header::SEC_WEBSOCKET_ACCEPT); + s.insert(http::header::SEC_WEBSOCKET_KEY); + s.insert(http::header::SEC_WEBSOCKET_VERSION); + s.insert(http::header::UPGRADE); + s.insert(http::header::CONNECTION); + s.insert(http::header::STRICT_TRANSPORT_SECURITY); + s + }; +} + +fn construct_uri(req: &Request, options: &TargetHTTPOptions, websocket: bool) -> Uri { + let target_uri = Uri::try_from(options.url.clone()).unwrap(); + let source_uri = req.uri().clone(); + + let authority = target_uri.authority().unwrap().to_string(); + let authority = authority.split("@").last().unwrap(); + let authority: Authority = authority.try_into().unwrap(); + let mut uri = http::uri::Builder::new() + .authority(authority) + .path_and_query(source_uri.path_and_query().unwrap().clone()); + + uri = uri.scheme(target_uri.scheme().unwrap().clone()); + + if websocket { + uri = uri.scheme( + Scheme::from_str( + if target_uri.scheme().unwrap() == &Scheme::from_str("http").unwrap() { + "ws" + } else { + "wss" + }, + ) + .unwrap(), + ); + } + + uri.build().unwrap() +} + +fn copy_client_response( + client_response: &R, + server_response: &mut poem::Response, +) { + let mut headers = client_response.headers().clone(); + for h in client_response.headers().iter() { + if DONT_FORWARD_HEADERS.contains(h.0) { + if let http::header::Entry::Occupied(e) = headers.entry(h.0) { + e.remove_entry(); + } + } + } + server_response.headers_mut().extend(headers.into_iter()); + + server_response.set_status(client_response.status()); +} + +fn rewrite_request(mut req: B, options: &TargetHTTPOptions) -> Result { + if let Some(ref headers) = options.headers { + for (k, v) in headers { + req = req.header(HeaderName::try_from(k)?, v.parse()?); + } + } + Ok(req) +} + +fn rewrite_response(resp: &mut Response, options: &TargetHTTPOptions) -> Result<()> { + let target_uri = Uri::try_from(options.url.clone()).unwrap(); + let headers = resp.headers_mut(); + + if let Some(value) = headers.get_mut(http::header::LOCATION) { + let redirect_uri = Uri::try_from(value.as_bytes()).unwrap(); + if redirect_uri.authority() == target_uri.authority() { + let old_value = value.clone(); + *value = Uri::builder() + .path_and_query(redirect_uri.path_and_query().unwrap().clone()) + .build() + .unwrap() + .to_string() + .parse() + .unwrap(); + debug!("Rewrote a redirect from {:?} to {:?}", old_value, value); + } + } + + if let http::header::Entry::Occupied(mut entry) = headers.entry(http::header::SET_COOKIE) { + for value in entry.iter_mut() { + try_block!({ + let mut cookie = Cookie::parse(value.to_str()?)?; + cookie.set_expires(cookie::Expiration::Session); + *value = cookie.to_string().parse()?; + } catch (error: anyhow::Error) { + warn!(?error, header=?value, "Failed to parse response cookie") + }) + } + } + + Ok(()) +} + +fn copy_server_request(req: &Request, mut target: B) -> B { + for k in req.headers().keys() { + if DONT_FORWARD_HEADERS.contains(k) { + continue; + } + target = target.header( + k.clone(), + req.headers() + .get_all(k) + .iter() + .map(|v| v.to_str().unwrap().to_string()) + .collect::>() + .join("; "), + ); + } + target +} + +pub async fn proxy_normal_request( + req: &Request, + body: Body, + options: &TargetHTTPOptions, +) -> poem::Result { + let uri = construct_uri(req, &options, false).to_string(); + + tracing::debug!("URI: {:?}", uri); + + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .connection_verbose(true) + .build() + .unwrap(); + let mut client_request = client.request(req.method().into(), uri.clone()); + + client_request = copy_server_request(&req, client_request); + client_request = rewrite_request(client_request, options)?; + client_request = client_request.body(reqwest::Body::wrap_stream(body.into_bytes_stream())); + + let client_request = client_request.build().unwrap(); + let client_response = client.execute(client_request).await.unwrap(); + let status = client_response.status().clone(); + + let mut response: Response = "".into(); + + copy_client_response(&client_response, &mut response); + copy_client_body(client_response, &mut response).await?; + + log_request_result(req.method(), req.original_uri(), &status); + + rewrite_response(&mut response, options)?; + Ok(response) +} + +async fn copy_client_body( + client_response: reqwest::Response, + response: &mut Response, +) -> Result<()> { + if response.content_type().map(|c| c.starts_with("text/html")) == Some(true) + && response.status() == 200 + { + copy_client_body_and_embed(client_response, response).await?; + return Ok(()); + } + + response.set_body(Body::from_bytes_stream(client_response.bytes_stream())); + Ok(()) +} + +async fn copy_client_body_and_embed( + client_response: reqwest::Response, + response: &mut Response, +) -> Result<()> { + let content = client_response.text().await?; + + let script_manifest = lookup_built_file("src/embed/index.ts")?; + + let mut inject = format!( + r#""#, + script_manifest.file + ); + for css_file in script_manifest.css.unwrap_or(vec![]) { + inject += &format!( + r#""#, + css_file + ); + } + + let before = ""; + let content = content.replacen(before, &format!("{}{}", inject, before), 1); + + response.headers_mut().remove(http::header::CONTENT_LENGTH); + response + .headers_mut() + .remove(http::header::CONTENT_ENCODING); + response.headers_mut().remove(http::header::CONTENT_TYPE); + response + .headers_mut() + .remove(http::header::TRANSFER_ENCODING); + response.headers_mut().insert( + http::header::CONTENT_TYPE, + "text/html; charset=utf-8".parse()?, + ); + response.set_body(content); + Ok(()) +} + +pub async fn proxy_websocket_request( + req: &Request, + ws: WebSocket, + options: &TargetHTTPOptions, +) -> poem::Result { + let uri = construct_uri(req, &options, true); + proxy_ws_inner(req, ws, uri.clone(), options) + .await + .map_err(|error| { + tracing::error!(?uri, ?error, "WebSocket proxy failed"); + error + }) +} + +async fn proxy_ws_inner( + req: &Request, + ws: WebSocket, + uri: Uri, + options: &TargetHTTPOptions, +) -> poem::Result { + let mut client_request = http::request::Builder::new() + .uri(uri.clone()) + .header(http::header::CONNECTION, "Upgrade") + .header(http::header::UPGRADE, "websocket") + .header(http::header::SEC_WEBSOCKET_VERSION, "13") + .header( + http::header::SEC_WEBSOCKET_KEY, + tungstenite::handshake::client::generate_key(), + ); + + client_request = copy_server_request(&req, client_request); + client_request = rewrite_request(client_request, options)?; + + let (client, client_response) = connect_async_with_config( + client_request + .body(()) + .map_err(poem::error::InternalServerError)?, + None, + ) + .await + .map_err(poem::error::BadGateway)?; + + tracing::info!("{:?} {:?} - WebSocket", client_response.status(), uri); + + let mut response = ws + .on_upgrade(|socket| async move { + let (mut client_sink, mut client_source) = client.split(); + + let (mut server_sink, mut server_source) = socket.split(); + + if let Err(error) = { + let server_to_client = tokio::spawn(async move { + while let Some(msg) = server_source.next().await { + tracing::debug!("Server: {:?}", msg); + match msg? { + Message::Binary(data) => { + client_sink.send(tungstenite::Message::Binary(data)).await?; + } + Message::Text(text) => { + client_sink.send(tungstenite::Message::Text(text)).await?; + } + Message::Ping(data) => { + client_sink.send(tungstenite::Message::Ping(data)).await?; + } + Message::Pong(data) => { + client_sink.send(tungstenite::Message::Pong(data)).await?; + } + Message::Close(data) => { + client_sink + .send(tungstenite::Message::Close(data.map(|data| { + tungstenite::protocol::CloseFrame { + code: data.0.into(), + reason: Cow::Owned(data.1), + } + }))) + .await?; + } + } + } + Ok::<_, anyhow::Error>(()) + }); + + let client_to_server = tokio::spawn(async move { + while let Some(msg) = client_source.next().await { + tracing::debug!("Client: {:?}", msg); + match msg? { + tungstenite::Message::Binary(data) => { + server_sink.send(Message::Binary(data)).await?; + } + tungstenite::Message::Text(text) => { + server_sink.send(Message::Text(text)).await?; + } + tungstenite::Message::Ping(data) => { + server_sink.send(Message::Ping(data)).await?; + } + tungstenite::Message::Pong(data) => { + server_sink.send(Message::Pong(data)).await?; + } + tungstenite::Message::Close(data) => { + server_sink + .send(Message::Close(data.map(|data| { + ( + CloseCode::from(data.code), + data.reason.to_owned().to_string(), + ) + }))) + .await?; + } + tungstenite::Message::Frame(_) => unreachable!(), + } + } + Ok::<_, anyhow::Error>(()) + }); + + server_to_client.await??; + client_to_server.await??; + debug!("Closing Websocket stream"); + + Ok::<_, anyhow::Error>(()) + } { + error!(?error, "Websocket stream error"); + } + Ok::<_, anyhow::Error>(()) + }) + .into_response(); + + copy_client_response(&client_response, &mut response); + rewrite_response(&mut response, options)?; + Ok(response) +} diff --git a/warpgate-protocol-http/src/session.rs b/warpgate-protocol-http/src/session.rs new file mode 100644 index 0000000..2062bf3 --- /dev/null +++ b/warpgate-protocol-http/src/session.rs @@ -0,0 +1,175 @@ +use crate::common::SESSION_MAX_AGE; +use crate::session_handle::{ + HttpSessionHandle, SessionHandleCommand, WarpgateServerHandleFromRequest, +}; +use poem::session::{Session, SessionStorage}; +use poem::web::{Data, RemoteAddr}; +use poem::{FromRequest, Request}; +use serde_json::Value; +use std::collections::{BTreeMap, HashMap}; +use std::sync::{Arc, Weak}; +use std::time::{Duration, Instant}; +use tokio::sync::Mutex; +use warpgate_common::{Services, SessionId, SessionState, WarpgateServerHandle}; + +#[derive(Clone)] +pub struct SharedSessionStorage(pub Arc>>); + +static POEM_SESSION_ID_SESSION_KEY: &str = "poem_session_id"; + +#[async_trait::async_trait] +impl SessionStorage for SharedSessionStorage { + async fn load_session( + &self, + session_id: &str, + ) -> poem::Result>> { + self.0.lock().await.load_session(session_id).await.map(|o| { + o.map(|mut s| { + s.insert( + POEM_SESSION_ID_SESSION_KEY.to_string(), + session_id.to_string().into(), + ); + s + }) + }) + } + + async fn update_session( + &self, + session_id: &str, + entries: &BTreeMap, + expires: Option, + ) -> poem::Result<()> { + self.0 + .lock() + .await + .update_session(session_id, entries, expires) + .await + } + + async fn remove_session(&self, session_id: &str) -> poem::Result<()> { + self.0.lock().await.remove_session(session_id).await + } +} + +pub struct SessionMiddleware { + session_handles: HashMap>>, + session_timestamps: HashMap, + this: Weak>, +} + +static SESSION_ID_SESSION_KEY: &str = "session_id"; +static SESSION_ID_REQUEST_COUNTER: &str = "request_counter"; + +impl SessionMiddleware { + pub fn new() -> Arc> { + Arc::new_cyclic(|me| { + Mutex::new(Self { + session_handles: HashMap::new(), + session_timestamps: HashMap::new(), + this: me.clone(), + }) + }) + } + + pub async fn process_request(&mut self, req: Request) -> poem::Result { + let session: &Session = <_>::from_request_without_body(&req).await?; + + let request_counter = session.get::(SESSION_ID_REQUEST_COUNTER).unwrap_or(0); + session.set(SESSION_ID_REQUEST_COUNTER, request_counter + 1); + + if let Some(session_id) = session.get::(SESSION_ID_SESSION_KEY) { + self.session_timestamps.insert(session_id, Instant::now()); + } else if request_counter == 5 { + // Start logging sessions when they've got 5 requests + self.create_handle_for(&req).await?; + }; + + Ok(req) + } + + pub async fn create_handle_for( + &mut self, + req: &Request, + ) -> poem::Result { + let session: &Session = <_>::from_request_without_body(&req).await?; + + if let Some(handle) = self.handle_for(session) { + return Ok(handle.into()); + } + + let services = Data::<&Services>::from_request_without_body(&req).await?; + let remote_address: &RemoteAddr = <_>::from_request_without_body(&req).await?; + let session_storage = + Data::<&SharedSessionStorage>::from_request_without_body(&req).await?; + + let (session_handle, mut session_handle_rx) = HttpSessionHandle::new(); + let session_state = Arc::new(Mutex::new(SessionState::new( + remote_address.0.as_socket_addr().cloned(), + Box::new(session_handle), + ))); + + let server_handle = services + .state + .lock() + .await + .register_session(&crate::PROTOCOL_NAME, &session_state) + .await?; + + let id = server_handle.lock().await.id(); + self.session_handles.insert(id, server_handle.clone()); + + session.set(SESSION_ID_SESSION_KEY, id); + + let this = self.this.upgrade().unwrap(); + tokio::spawn({ + let session_storage = (*session_storage).clone(); + let poem_session_id: Option = session.get(POEM_SESSION_ID_SESSION_KEY); + let id = id.clone(); + async move { + while let Some(command) = session_handle_rx.recv().await { + match command { + SessionHandleCommand::Close => { + if let Some(ref poem_session_id) = poem_session_id { + let _ = session_storage.remove_session(&poem_session_id).await; + } + this.lock().await.session_handles.remove(&id); + } + } + } + Ok::<_, anyhow::Error>(()) + } + }); + + self.session_timestamps.insert(id, Instant::now()); + + Ok(server_handle.into()) + } + + pub fn handle_for(&self, session: &Session) -> Option>> { + session + .get::(SESSION_ID_SESSION_KEY) + .and_then(|id| self.session_handles.get(&id).cloned()) + } + + pub fn remove_session(&mut self, session: &Session) { + if let Some(id) = session.get::(SESSION_ID_SESSION_KEY) { + self.session_handles.remove(&id); + self.session_timestamps.remove(&id); + } + } + + pub async fn vacuum(&mut self) { + let now = Instant::now(); + let mut to_remove = vec![]; + for (id, timestamp) in self.session_timestamps.iter() { + if now.duration_since(*timestamp) > SESSION_MAX_AGE { + to_remove.push(*id); + } + } + for id in to_remove { + self.session_handles.remove(&id); + self.session_timestamps.remove(&id); + } + } +} diff --git a/warpgate-protocol-http/src/session_handle.rs b/warpgate-protocol-http/src/session_handle.rs new file mode 100644 index 0000000..b6a54f2 --- /dev/null +++ b/warpgate-protocol-http/src/session_handle.rs @@ -0,0 +1,64 @@ +use std::any::type_name; +use std::sync::Arc; + +use poem::error::GetDataError; +use poem::session::Session; +use poem::web::Data; +use poem::{FromRequest, Request, RequestBody}; +use tokio::sync::{mpsc, Mutex}; +use warpgate_common::{SessionHandle, WarpgateServerHandle}; + +use crate::session::SessionMiddleware; + +#[derive(Clone, Debug, PartialEq)] +pub enum SessionHandleCommand { + Close, +} + +pub struct HttpSessionHandle { + sender: mpsc::UnboundedSender, +} + +impl HttpSessionHandle { + pub fn new() -> (Self, mpsc::UnboundedReceiver) { + let (sender, receiver) = mpsc::unbounded_channel(); + (HttpSessionHandle { sender }, receiver) + } +} + +impl SessionHandle for HttpSessionHandle { + fn close(&mut self) { + let _ = self.sender.send(SessionHandleCommand::Close); + } +} + +#[derive(Clone)] +pub struct WarpgateServerHandleFromRequest(pub Arc>); + +impl std::ops::Deref for WarpgateServerHandleFromRequest { + type Target = Arc>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[async_trait::async_trait] +impl<'a> FromRequest<'a> for WarpgateServerHandleFromRequest { + async fn from_request(req: &'a Request, _: &mut RequestBody) -> poem::Result { + let sm = Data::<&Arc>>::from_request_without_body(req).await?; + let session: &Session = <_>::from_request_without_body(&req).await?; + Ok(sm + .lock() + .await + .handle_for(session) + .map(WarpgateServerHandleFromRequest) + .ok_or_else(|| GetDataError(type_name::()))?) + } +} + +impl From>> for WarpgateServerHandleFromRequest { + fn from(handle: Arc>) -> Self { + WarpgateServerHandleFromRequest(handle) + } +} diff --git a/warpgate-protocol-ssh/src/client/mod.rs b/warpgate-protocol-ssh/src/client/mod.rs index 721ee39..48d48c2 100644 --- a/warpgate-protocol-ssh/src/client/mod.rs +++ b/warpgate-protocol-ssh/src/client/mod.rs @@ -13,8 +13,8 @@ use channel_session::SessionChannel; use futures::pin_mut; use handler::ClientHandler; use russh::client::Handle; -use russh::{Sig, Preferred}; -use russh_keys::key::{PublicKey, self}; +use russh::{Preferred, Sig}; +use russh_keys::key::{self, PublicKey}; use std::collections::HashMap; use std::net::ToSocketAddrs; use std::sync::Arc; @@ -328,7 +328,12 @@ impl RemoteClient { info!(?address, username = &ssh_options.username[..], "Connecting"); let config = russh::client::Config { preferred: Preferred { - key: &[key::ED25519, key::RSA_SHA2_256, key::RSA_SHA2_512, key::SSH_RSA], + key: &[ + key::ED25519, + key::RSA_SHA2_256, + key::RSA_SHA2_512, + key::SSH_RSA, + ], ..<_>::default() }, ..Default::default() @@ -365,18 +370,18 @@ impl RemoteClient { return Err(ConnectionError::Aborted) } session = &mut fut_connect => { - if let Err(error) = session { - let connection_error = match error { - ClientHandlerError::ConnectionError(e) => e, - ClientHandlerError::Ssh(e) => ConnectionError::SSH(e), - ClientHandlerError::Internal => ConnectionError::Internal, - }; - error!(error=?connection_error, "Connection error"); - return Err(connection_error); - } - - #[allow(clippy::unwrap_used)] - let mut session = session.unwrap(); + let mut session = match session { + Ok(session) => session, + Err(error) => { + let connection_error = match error { + ClientHandlerError::ConnectionError(e) => e, + ClientHandlerError::Ssh(e) => ConnectionError::SSH(e), + ClientHandlerError::Internal => ConnectionError::Internal, + }; + error!(error=?connection_error, "Connection error"); + return Err(connection_error); + } + }; let mut auth_result = false; match ssh_options.auth { diff --git a/warpgate-protocol-ssh/src/lib.rs b/warpgate-protocol-ssh/src/lib.rs index ad112e1..bae2a86 100644 --- a/warpgate-protocol-ssh/src/lib.rs +++ b/warpgate-protocol-ssh/src/lib.rs @@ -6,7 +6,6 @@ pub mod helpers; mod keys; mod known_hosts; mod server; - use crate::client::{RCCommand, RemoteClient}; use anyhow::Result; use async_trait::async_trait; @@ -18,7 +17,11 @@ pub use server::run_server; use std::fmt::Debug; use std::net::SocketAddr; use uuid::Uuid; -use warpgate_common::{ProtocolServer, Services, Target, TargetTestError}; +use warpgate_common::{ + ProtocolName, ProtocolServer, Services, Target, TargetOptions, TargetTestError, +}; + +pub static PROTOCOL_NAME: ProtocolName = "SSH"; #[derive(Clone)] pub struct SSHProtocolServer { @@ -43,7 +46,7 @@ impl ProtocolServer for SSHProtocolServer { } async fn test_target(self, target: Target) -> Result<(), TargetTestError> { - let Some(ssh_options) = target.ssh else { + let TargetOptions::Ssh(ssh_options) = target.options else { return Err(TargetTestError::Misconfigured("Not an SSH target".to_owned())); }; diff --git a/warpgate-protocol-ssh/src/server/mod.rs b/warpgate-protocol-ssh/src/server/mod.rs index 26ad244..4ac7b78 100644 --- a/warpgate-protocol-ssh/src/server/mod.rs +++ b/warpgate-protocol-ssh/src/server/mod.rs @@ -37,7 +37,7 @@ pub async fn run_server(services: Services, address: SocketAddr) -> Result<()> { let (session_handle, session_handle_rx) = SSHSessionHandle::new(); let session_state = Arc::new(Mutex::new(SessionState::new( - remote_address, + Some(remote_address), Box::new(session_handle), ))); @@ -45,10 +45,10 @@ pub async fn run_server(services: Services, address: SocketAddr) -> Result<()> { .state .lock() .await - .register_session(&session_state) + .register_session(&crate::PROTOCOL_NAME, &session_state) .await?; - let id = server_handle.id(); + let id = server_handle.lock().await.id(); let session = match ServerSession::new(remote_address, &services, server_handle, session_handle_rx) diff --git a/warpgate-protocol-ssh/src/server/russh_handler.rs b/warpgate-protocol-ssh/src/server/russh_handler.rs index 82b55b7..2c243f2 100644 --- a/warpgate-protocol-ssh/src/server/russh_handler.rs +++ b/warpgate-protocol-ssh/src/server/russh_handler.rs @@ -89,7 +89,7 @@ impl russh::server::Handler for ServerHandler { ) -> Self::FutureUnit { let term = term.to_string(); let modes = modes - .into_iter() + .iter() .take_while(|x| (x.0 as u8) > 0 && (x.0 as u8) < 160) .map(Clone::clone) .collect(); diff --git a/warpgate-protocol-ssh/src/server/session.rs b/warpgate-protocol-ssh/src/server/session.rs index 364b2b5..cdfc0c7 100644 --- a/warpgate-protocol-ssh/src/server/session.rs +++ b/warpgate-protocol-ssh/src/server/session.rs @@ -32,7 +32,7 @@ use warpgate_common::recordings::{ }; use warpgate_common::{ authorize_ticket, AuthCredential, AuthResult, Secret, Services, SessionId, Target, - TargetSSHOptions, WarpgateServerHandle, + TargetOptions, TargetSSHOptions, WarpgateServerHandle, }; #[derive(Clone)] @@ -64,7 +64,7 @@ pub struct ServerSession { rc_state: RCState, remote_address: SocketAddr, services: Services, - server_handle: WarpgateServerHandle, + server_handle: Arc>, target: TargetSelection, traffic_recorders: HashMap<(String, u32), TrafficRecorder>, traffic_connection_recorders: HashMap, @@ -88,10 +88,10 @@ impl ServerSession { pub async fn new( remote_address: SocketAddr, services: &Services, - server_handle: WarpgateServerHandle, + server_handle: Arc>, mut session_handle_rx: UnboundedReceiver, ) -> Result>> { - let id = server_handle.id(); + let id = server_handle.lock().await.id(); let _span = info_span!("SSH", session=%id); let _enter = _span.enter(); @@ -116,7 +116,7 @@ impl ServerSession { }); let this = Self { - id: server_handle.id(), + id, username: None, session_handle: None, pty_channels: vec![], @@ -884,7 +884,7 @@ impl ServerSession { match self.try_auth(&selector).await { Ok(AuthResult::Accepted { .. }) => russh::server::Auth::Accept, Ok(AuthResult::Rejected) => russh::server::Auth::Reject, - Ok(AuthResult::OTPNeeded) => russh::server::Auth::Reject, + Ok(AuthResult::OtpNeeded) => russh::server::Auth::Reject, Err(error) => { error!(?error, "Failed to verify credentials"); russh::server::Auth::Reject @@ -905,7 +905,7 @@ impl ServerSession { match self.try_auth(&selector).await { Ok(AuthResult::Accepted { .. }) => russh::server::Auth::Accept, Ok(AuthResult::Rejected) => russh::server::Auth::Reject, - Ok(AuthResult::OTPNeeded) => russh::server::Auth::Reject, + Ok(AuthResult::OtpNeeded) => russh::server::Auth::Reject, Err(error) => { error!(?error, "Failed to verify credentials"); russh::server::Auth::Reject @@ -922,14 +922,14 @@ impl ServerSession { info!("Keyboard-interactive auth as {:?}", selector); if let Some(otp) = response { - self.credentials.push(AuthCredential::OTP(otp)); + self.credentials.push(AuthCredential::Otp(otp)); } match self.try_auth(&selector).await { Ok(AuthResult::Accepted { .. }) => russh::server::Auth::Accept, Ok(AuthResult::Rejected) => russh::server::Auth::Reject, - Ok(AuthResult::OTPNeeded) => russh::server::Auth::Partial { - name: Cow::Borrowed("OTP"), + Ok(AuthResult::OtpNeeded) => russh::server::Auth::Partial { + name: Cow::Borrowed("Two-factor authentication"), instructions: Cow::Borrowed(""), prompts: Cow::Owned(vec![(Cow::Borrowed("One-time password: "), true)]), }, @@ -975,8 +975,7 @@ impl ServerSession { self._auth_accept(&username, target_name).await; Ok(AuthResult::Accepted { username }) } - AuthResult::Rejected => Ok(AuthResult::Rejected), - AuthResult::OTPNeeded => Ok(AuthResult::OTPNeeded), + x => Ok(x), } } AuthSelector::Ticket { secret } => { @@ -1003,7 +1002,12 @@ impl ServerSession { async fn _auth_accept(&mut self, username: &str, target_name: &str) { info!(username = username, "Authenticated"); - let _ = self.server_handle.set_username(username.to_string()).await; + let _ = self + .server_handle + .lock() + .await + .set_username(username.to_string()) + .await; self.username = Some(username.to_string()); let target = { @@ -1014,9 +1018,12 @@ impl ServerSession { .store .targets .iter() - .find(|x| x.name == target_name) - .filter(|x| x.ssh.is_some()) - .map(|x| (x.clone(), x.ssh.clone().unwrap())) + .filter_map(|t| match t.options { + TargetOptions::Ssh(ref options) => Some((t, options)), + _ => None, + }) + .find(|(t, _)| t.name == target_name) + .map(|(t, opt)| (t.clone(), opt.clone())) }; let Some((target, ssh_options)) = target else { @@ -1025,7 +1032,7 @@ impl ServerSession { return; }; - let _ = self.server_handle.set_target(&target).await; + let _ = self.server_handle.lock().await.set_target(&target).await; self.target = TargetSelection::Found(target, ssh_options); } diff --git a/warpgate-admin/app/.editorconfig b/warpgate-web/.editorconfig similarity index 100% rename from warpgate-admin/app/.editorconfig rename to warpgate-web/.editorconfig diff --git a/warpgate-admin/app/.eslintrc.yaml b/warpgate-web/.eslintrc.yaml similarity index 99% rename from warpgate-admin/app/.eslintrc.yaml rename to warpgate-web/.eslintrc.yaml index fe4f9ff..82362f0 100644 --- a/warpgate-admin/app/.eslintrc.yaml +++ b/warpgate-web/.eslintrc.yaml @@ -154,3 +154,4 @@ overrides: ignorePatterns: - svelte.config.js - vite.config.ts +- src/*/lib/api-client/** diff --git a/warpgate-admin/app/.gitignore b/warpgate-web/.gitignore similarity index 100% rename from warpgate-admin/app/.gitignore rename to warpgate-web/.gitignore diff --git a/warpgate-web/Cargo.toml b/warpgate-web/Cargo.toml new file mode 100644 index 0000000..d51d79b --- /dev/null +++ b/warpgate-web/Cargo.toml @@ -0,0 +1,11 @@ +[package] +edition = "2021" +license = "Apache-2.0" +name = "warpgate-web" +version = "0.1.0" + +[dependencies] +rust-embed = "6.3" +serde = "1.0" +serde_json = "1.0" +thiserror = "1.0" diff --git a/warpgate-admin/app/openapitools.json b/warpgate-web/openapitools.json similarity index 100% rename from warpgate-admin/app/openapitools.json rename to warpgate-web/openapitools.json diff --git a/warpgate-admin/app/package.json b/warpgate-web/package.json similarity index 62% rename from warpgate-admin/app/package.json rename to warpgate-web/package.json index ba3c2c4..65b68b3 100644 --- a/warpgate-admin/app/package.json +++ b/warpgate-web/package.json @@ -10,10 +10,12 @@ "preview": "vite preview", "check": "svelte-check --tsconfig ./tsconfig.json", "lint": "eslint src && svelte-check", - "postinstall": "yarn run openapi-client", - "openapi-schema": "curl -k https://localhost:8888/api/openapi.json > openapi-schema.json", - "openapi-client": "openapi-generator-cli generate -g typescript-fetch -i openapi-schema.json -o api-client -p npmName=warpgate-api-client -p useSingleRequestParameter=true && cd api-client && npm i && yarn tsc --target esnext --module esnext && rm -rf src", - "openapi": "yarn run openapi-schema && yarn run openapi-client" + "postinstall": "yarn run openapi:client:gateway && yarn run openapi:client:admin", + "openapi:schema:gateway": "cargo run -p warpgate-protocol-http > src/gateway/lib/openapi-schema.json", + "openapi:schema:admin": "cargo run -p warpgate-admin > src/admin/lib/openapi-schema.json", + "openapi:client:gateway": "openapi-generator-cli generate -g typescript-fetch -i src/gateway/lib/openapi-schema.json -o src/gateway/lib/api-client -p npmName=warpgate-gateway-api-client -p useSingleRequestParameter=true && cd src/gateway/lib/api-client && npm i && yarn tsc --target esnext --module esnext && rm -rf src", + "openapi:client:admin": "openapi-generator-cli generate -g typescript-fetch -i src/admin/lib/openapi-schema.json -o src/admin/lib/api-client -p npmName=warpgate-admin-api-client -p useSingleRequestParameter=true && cd src/admin/lib/api-client && npm i && yarn tsc --target esnext --module esnext && rm -rf src", + "openapi": "yarn run openapi:schema:admin && yarn run openapi:schema:gateway && yarn run openapi:client:admin && yarn run openapi:client:gateway" }, "devDependencies": { "@fontsource/work-sans": "^4.5.7", diff --git a/warpgate-admin/app/public/assets/logo.svg b/warpgate-web/public/assets/logo.svg similarity index 100% rename from warpgate-admin/app/public/assets/logo.svg rename to warpgate-web/public/assets/logo.svg diff --git a/warpgate-admin/app/src/App.svelte b/warpgate-web/src/admin/App.svelte similarity index 52% rename from warpgate-admin/app/src/App.svelte rename to warpgate-web/src/admin/App.svelte index 180847d..1b0b9e2 100644 --- a/warpgate-admin/app/src/App.svelte +++ b/warpgate-web/src/admin/App.svelte @@ -1,30 +1,24 @@ -
-
- - - - {#if $authenticatedUsername} - Sessions - Targets - Tickets - SSH - Log - {/if} - {#if $authenticatedUsername} -
- -
- - {/if} -
-
- -
-
- {version} -
-
+{#await init()} + +{:then} +
+
+ + + + {#if $serverInfo?.username} + Sessions + Targets + Tickets + SSH + Log + {/if} + {#if $serverInfo?.username} +
+ {$serverInfo?.username} +
+ + {/if} +
+
+ +
+
+ {$serverInfo?.version} +
+
+{/await} diff --git a/warpgate-admin/app/src/CreateTicket.svelte b/warpgate-web/src/admin/CreateTicket.svelte similarity index 88% rename from warpgate-admin/app/src/CreateTicket.svelte rename to warpgate-web/src/admin/CreateTicket.svelte index 44cad8b..16c46a0 100644 --- a/warpgate-admin/app/src/CreateTicket.svelte +++ b/warpgate-web/src/admin/CreateTicket.svelte @@ -1,7 +1,8 @@ -{#if !sessions} +{#if !$sessions} {:else}
{#if $activeSessions }

Sessions right now: {$activeSessions}

- +
+ + Close all sessions + +
{:else}

No active sessions

{/if} @@ -67,6 +69,7 @@ {/if}
+
{session.protocol}
{describeSession(session)} @@ -101,6 +104,10 @@ align-items: center; } + .protocol { + min-width: 3rem; + } + .meta { opacity: .75; margin-left: 25px; diff --git a/warpgate-admin/app/src/Log.svelte b/warpgate-web/src/admin/Log.svelte similarity index 72% rename from warpgate-admin/app/src/Log.svelte rename to warpgate-web/src/admin/Log.svelte index f196f6b..e2637e7 100644 --- a/warpgate-admin/app/src/Log.svelte +++ b/warpgate-web/src/admin/Log.svelte @@ -1,5 +1,5 @@
diff --git a/warpgate-admin/app/src/LogViewer.svelte b/warpgate-web/src/admin/LogViewer.svelte similarity index 99% rename from warpgate-admin/app/src/LogViewer.svelte rename to warpgate-web/src/admin/LogViewer.svelte index b84f459..2c6b8d4 100644 --- a/warpgate-admin/app/src/LogViewer.svelte +++ b/warpgate-web/src/admin/LogViewer.svelte @@ -1,5 +1,5 @@ diff --git a/warpgate-admin/app/src/SSH.svelte b/warpgate-web/src/admin/SSH.svelte similarity index 96% rename from warpgate-admin/app/src/SSH.svelte rename to warpgate-web/src/admin/SSH.svelte index a2a1d07..e279c19 100644 --- a/warpgate-admin/app/src/SSH.svelte +++ b/warpgate-web/src/admin/SSH.svelte @@ -1,5 +1,5 @@ + diff --git a/warpgate-web/src/admin/index.ts b/warpgate-web/src/admin/index.ts new file mode 100644 index 0000000..5651aa6 --- /dev/null +++ b/warpgate-web/src/admin/index.ts @@ -0,0 +1,10 @@ +import '@fontsource/work-sans' +import '../theme.scss' +import App from './App.svelte' + +new App({ + target: document.getElementById('app')!, +}) + +// eslint-disable-next-line @typescript-eslint/no-useless-empty-export +export { } diff --git a/warpgate-web/src/admin/lib/api.ts b/warpgate-web/src/admin/lib/api.ts new file mode 100644 index 0000000..e218f51 --- /dev/null +++ b/warpgate-web/src/admin/lib/api.ts @@ -0,0 +1,8 @@ +import { DefaultApi, Configuration } from './api-client/dist' + +const configuration = new Configuration({ + basePath: '/@warpgate/admin/api', +}) + +export const api = new DefaultApi(configuration) +export * from './api-client' diff --git a/warpgate-admin/app/openapi-schema.json b/warpgate-web/src/admin/lib/openapi-schema.json similarity index 87% rename from warpgate-admin/app/openapi-schema.json rename to warpgate-web/src/admin/lib/openapi-schema.json index cf4550d..2db90bc 100644 --- a/warpgate-admin/app/openapi-schema.json +++ b/warpgate-web/src/admin/lib/openapi-schema.json @@ -1,12 +1,12 @@ { "openapi": "3.0.0", "info": { - "title": "Warpgate", - "version": "0.1.1" + "title": "Warpgate Web Admin", + "version": "0.2.5" }, "servers": [ { - "url": "/api" + "url": "/@warpgate/admin/api" } ], "tags": [], @@ -325,56 +325,6 @@ "operationId": "delete_ssh_known_host" } }, - "/info": { - "get": { - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Info" - } - } - } - } - }, - "operationId": "get_info" - } - }, - "/auth/login": { - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LoginRequest" - } - } - }, - "required": true - }, - "responses": { - "201": { - "description": "" - }, - "401": { - "description": "" - } - }, - "operationId": "login" - } - }, - "/auth/logout": { - "post": { - "responses": { - "201": { - "description": "" - } - }, - "operationId": "logout" - } - }, "/ssh/own-keys": { "get": { "responses": { @@ -470,20 +420,6 @@ } } }, - "Info": { - "type": "object", - "required": [ - "version" - ], - "properties": { - "version": { - "type": "string" - }, - "username": { - "type": "string" - } - } - }, "LogEntry": { "type": "object", "required": [ @@ -515,21 +451,6 @@ } } }, - "LoginRequest": { - "type": "object", - "required": [ - "username", - "password" - ], - "properties": { - "username": { - "type": "string" - }, - "password": { - "type": "string" - } - } - }, "Recording": { "type": "object", "required": [ @@ -619,7 +540,8 @@ "type": "object", "required": [ "id", - "started" + "started", + "protocol" ], "properties": { "id": { @@ -643,6 +565,9 @@ "ticket_id": { "type": "string", "format": "uuid" + }, + "protocol": { + "type": "string" } } }, @@ -650,7 +575,8 @@ "type": "object", "required": [ "name", - "allow_roles" + "allow_roles", + "options" ], "properties": { "name": { @@ -662,14 +588,96 @@ "type": "string" } }, - "ssh": { - "$ref": "#/components/schemas/TargetSSHOptions" - }, - "web_admin": { - "$ref": "#/components/schemas/TargetWebAdminOptions" + "options": { + "$ref": "#/components/schemas/TargetOptions" } } }, + "TargetHTTPOptions": { + "type": "object", + "required": [ + "url" + ], + "properties": { + "url": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "TargetOptions": { + "type": "object", + "anyOf": [ + { + "required": [ + "kind" + ], + "allOf": [ + { + "$ref": "#/components/schemas/TargetSSHOptions" + }, + { + "type": "object", + "title": "TargetSSHOptions", + "properties": { + "kind": { + "type": "string", + "example": "TargetSSHOptions" + } + } + } + ] + }, + { + "required": [ + "kind" + ], + "allOf": [ + { + "$ref": "#/components/schemas/TargetHTTPOptions" + }, + { + "type": "object", + "title": "TargetHTTPOptions", + "properties": { + "kind": { + "type": "string", + "example": "TargetHTTPOptions" + } + } + } + ] + }, + { + "required": [ + "kind" + ], + "allOf": [ + { + "$ref": "#/components/schemas/TargetWebAdminOptions" + }, + { + "type": "object", + "title": "TargetWebAdminOptions", + "properties": { + "kind": { + "type": "string", + "example": "TargetWebAdminOptions" + } + } + } + ] + } + ], + "discriminator": { + "propertyName": "kind" + } + }, "TargetSSHOptions": { "type": "object", "required": [ @@ -754,4 +762,4 @@ } } } -} \ No newline at end of file +} diff --git a/warpgate-admin/app/src/lib/ssh.ts b/warpgate-web/src/admin/lib/ssh.ts similarity index 100% rename from warpgate-admin/app/src/lib/ssh.ts rename to warpgate-web/src/admin/lib/ssh.ts diff --git a/warpgate-admin/app/src/lib/time.ts b/warpgate-web/src/admin/lib/time.ts similarity index 100% rename from warpgate-admin/app/src/lib/time.ts rename to warpgate-web/src/admin/lib/time.ts diff --git a/warpgate-admin/app/src/player/TerminalRecordingPlayer.svelte b/warpgate-web/src/admin/player/TerminalRecordingPlayer.svelte similarity index 97% rename from warpgate-admin/app/src/player/TerminalRecordingPlayer.svelte rename to warpgate-web/src/admin/player/TerminalRecordingPlayer.svelte index 4a03933..43e4c18 100644 --- a/warpgate-admin/app/src/player/TerminalRecordingPlayer.svelte +++ b/warpgate-web/src/admin/player/TerminalRecordingPlayer.svelte @@ -6,7 +6,7 @@ import { faPlay, faPause, faExpand } from '@fortawesome/free-solid-svg-icons' import { Spinner } from 'sveltestrap' import formatDuration from 'format-duration' - import type { Recording } from 'lib/api' + import type { Recording } from 'admin/lib/api' export let recording: Recording @@ -91,7 +91,7 @@ throw new Error('Invalid recording type') } - url = `/api/recordings/${recording.id}/cast` + url = `/@warpgate/admin/api/recordings/${recording.id}/cast` term.loadAddon(serializeAddon) term.open(containerElement) @@ -110,7 +110,7 @@ await seek(duration) - socket = new WebSocket(`wss://${location.host}/api/recordings/${recording.id}/stream`) + socket = new WebSocket(`wss://${location.host}/@warpgate/admin/api/recordings/${recording.id}/stream`) socket.addEventListener('message', function (event) { let message = JSON.parse(event.data) if ('data' in message) { @@ -349,7 +349,7 @@
diff --git a/warpgate-web/src/embed/index.ts b/warpgate-web/src/embed/index.ts new file mode 100644 index 0000000..896e024 --- /dev/null +++ b/warpgate-web/src/embed/index.ts @@ -0,0 +1,23 @@ +import { api } from 'gateway/lib/api' +import EmbeddedUI from './EmbeddedUI.svelte' + +// eslint-disable-next-line @typescript-eslint/no-useless-empty-export +export { } + +navigator.serviceWorker.getRegistrations().then(registrations => { + for (const registration of registrations) { + registration.unregister() + } +}) + +api.getInfo().then(info => { + console.log(`Warpgate v${info.version}, logged in as ${info.username}`) +}) + +const container = document.createElement('div') +container.id = 'warpgate-embedded-ui' +document.body.appendChild(container) + +setTimeout(() => new EmbeddedUI({ + target: container, +})) diff --git a/warpgate-web/src/gateway/App.svelte b/warpgate-web/src/gateway/App.svelte new file mode 100644 index 0000000..466ebee --- /dev/null +++ b/warpgate-web/src/gateway/App.svelte @@ -0,0 +1,77 @@ + + + + +
+{#await init()} + +{:then _} + {#if redirecting} + + {:else} +
+ + + {#if $serverInfo?.username} +
{$serverInfo.username}
+ + {/if} +
+ + {#if $serverInfo?.username} + redirecting = true} /> + {:else} + + {/if} + +
+ {$serverInfo?.version} +
+ {/if} +{:catch error} + {error} +{/await} +
+ + diff --git a/warpgate-web/src/gateway/Login.svelte b/warpgate-web/src/gateway/Login.svelte new file mode 100644 index 0000000..6e33ded --- /dev/null +++ b/warpgate-web/src/gateway/Login.svelte @@ -0,0 +1,129 @@ + + +
+
+

Welcome

+
+ + {#if !otpInputVisible} + + + + + + + + + {/if} + + {#if otpInputVisible} + + + + + {/if} + + Login + + {#if incorrectCredentials} + Incorrect credentials + {/if} + {#if error} + {error} + {/if} +
diff --git a/warpgate-web/src/gateway/TargetList.svelte b/warpgate-web/src/gateway/TargetList.svelte new file mode 100644 index 0000000..0d15ec4 --- /dev/null +++ b/warpgate-web/src/gateway/TargetList.svelte @@ -0,0 +1,96 @@ + + +{#if targets} + +{:else} + +{/if} + + selectedTarget = undefined}> + selectedTarget = undefined}> +
+ {selectedTarget?.name} +
+
+ + {#if selectedTarget?.kind === TargetKind.Ssh} +

Connection instructions

+ + + + + + + + + {/if} +
+
+ + diff --git a/warpgate-web/src/gateway/index.html b/warpgate-web/src/gateway/index.html new file mode 100644 index 0000000..53ad78b --- /dev/null +++ b/warpgate-web/src/gateway/index.html @@ -0,0 +1,14 @@ + + + + + + + + Warpgate + + +
+ + + diff --git a/warpgate-web/src/gateway/index.ts b/warpgate-web/src/gateway/index.ts new file mode 100644 index 0000000..5651aa6 --- /dev/null +++ b/warpgate-web/src/gateway/index.ts @@ -0,0 +1,10 @@ +import '@fontsource/work-sans' +import '../theme.scss' +import App from './App.svelte' + +new App({ + target: document.getElementById('app')!, +}) + +// eslint-disable-next-line @typescript-eslint/no-useless-empty-export +export { } diff --git a/warpgate-web/src/gateway/lib/api.ts b/warpgate-web/src/gateway/lib/api.ts new file mode 100644 index 0000000..9664808 --- /dev/null +++ b/warpgate-web/src/gateway/lib/api.ts @@ -0,0 +1,8 @@ +import { DefaultApi, Configuration } from './api-client/dist' + +const configuration = new Configuration({ + basePath: '/@warpgate/api', +}) + +export const api = new DefaultApi(configuration) +export * from './api-client' diff --git a/warpgate-web/src/gateway/lib/openapi-schema.json b/warpgate-web/src/gateway/lib/openapi-schema.json new file mode 100644 index 0000000..729a5a7 --- /dev/null +++ b/warpgate-web/src/gateway/lib/openapi-schema.json @@ -0,0 +1,188 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Warpgate HTTP proxy", + "version": "0.2.5" + }, + "servers": [ + { + "url": "/@warpgate/api" + } + ], + "tags": [], + "paths": { + "/auth/login": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "" + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginFailureResponse" + } + } + } + } + }, + "operationId": "login" + } + }, + "/auth/logout": { + "post": { + "responses": { + "201": { + "description": "" + } + }, + "operationId": "logout" + } + }, + "/info": { + "get": { + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Info" + } + } + } + } + }, + "operationId": "get_info" + } + }, + "/targets": { + "get": { + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Target" + } + } + } + } + } + }, + "operationId": "get_targets" + } + } + }, + "components": { + "schemas": { + "Info": { + "type": "object", + "required": [ + "version", + "ports" + ], + "properties": { + "version": { + "type": "string" + }, + "username": { + "type": "string" + }, + "selected_target": { + "type": "string" + }, + "ports": { + "$ref": "#/components/schemas/PortsInfo" + } + } + }, + "LoginFailureReason": { + "type": "string", + "enum": [ + "InvalidCredentials", + "OtpNeeded" + ] + }, + "LoginFailureResponse": { + "type": "object", + "required": [ + "reason" + ], + "properties": { + "reason": { + "$ref": "#/components/schemas/LoginFailureReason" + } + } + }, + "LoginRequest": { + "type": "object", + "required": [ + "username", + "password" + ], + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "otp": { + "type": "string" + } + } + }, + "PortsInfo": { + "type": "object", + "required": [ + "ssh" + ], + "properties": { + "ssh": { + "type": "integer", + "format": "uint16" + } + } + }, + "Target": { + "type": "object", + "required": [ + "name", + "kind" + ], + "properties": { + "name": { + "type": "string" + }, + "kind": { + "$ref": "#/components/schemas/TargetKind" + } + } + }, + "TargetKind": { + "type": "string", + "enum": [ + "Http", + "Ssh", + "WebAdmin" + ] + } + } + } +} diff --git a/warpgate-web/src/gateway/lib/store.ts b/warpgate-web/src/gateway/lib/store.ts new file mode 100644 index 0000000..5337d37 --- /dev/null +++ b/warpgate-web/src/gateway/lib/store.ts @@ -0,0 +1,8 @@ +import { writable } from 'svelte/store' +import { api, Info } from './api' + +export const serverInfo = writable(null) + +export async function reloadServerInfo (): Promise { + serverInfo.set(await api.getInfo()) +} diff --git a/warpgate-web/src/gateway/login.ts b/warpgate-web/src/gateway/login.ts new file mode 100644 index 0000000..1bb1458 --- /dev/null +++ b/warpgate-web/src/gateway/login.ts @@ -0,0 +1,8 @@ +import Login from './Login.svelte' + +const app = {} +new Login({ + target: document.getElementById('app')!, +}) + +export default app diff --git a/warpgate-web/src/lib.rs b/warpgate-web/src/lib.rs new file mode 100644 index 0000000..96a2714 --- /dev/null +++ b/warpgate-web/src/lib.rs @@ -0,0 +1,39 @@ +use std::collections::HashMap; + +use rust_embed::RustEmbed; +use serde::Deserialize; + +#[derive(RustEmbed)] +#[folder = "../warpgate-web/dist"] +pub struct Assets; + +#[derive(thiserror::Error, Debug)] +pub enum LookupError { + #[error("I/O")] + Io(#[from] std::io::Error), + + #[error("Serde")] + Serde(#[from] serde_json::Error), + + #[error("File not found in manifest")] + FileNotFound, + + #[error("Manifest not found")] + ManifestNotFound, +} + +#[derive(Deserialize, Clone)] +pub struct ManifestEntry { + pub file: String, + pub css: Option>, +} + +pub fn lookup_built_file(source: &str) -> Result { + let file = Assets::get("manifest.json").ok_or(LookupError::ManifestNotFound)?; + + let obj: HashMap = serde_json::from_slice(&file.data)?; + + obj.get(source) + .map(Clone::clone) + .ok_or(LookupError::FileNotFound) +} diff --git a/warpgate-admin/app/src/theme.scss b/warpgate-web/src/theme.scss similarity index 94% rename from warpgate-admin/app/src/theme.scss rename to warpgate-web/src/theme.scss index a15bff8..9a8e2ce 100644 --- a/warpgate-admin/app/src/theme.scss +++ b/warpgate-web/src/theme.scss @@ -90,3 +90,10 @@ a { padding: 0 10px; margin: 20px 0; } + +footer { + display: flex; + padding: 1rem 0; + margin: 2rem 0 1rem; + border-top: 1px solid rgba($body-color, .5); +} diff --git a/warpgate-admin/app/src/vars.scss b/warpgate-web/src/vars.scss similarity index 66% rename from warpgate-admin/app/src/vars.scss rename to warpgate-web/src/vars.scss index afc8273..a1f1c31 100644 --- a/warpgate-admin/app/src/vars.scss +++ b/warpgate-web/src/vars.scss @@ -8,6 +8,18 @@ $list-group-bg: transparent; $alert-border-radius: 0; $alert-border-scale: -30%; + +$blue: #306F84 !default; +$purple: #5C398F !default; +$pink: #B53D6D !default; +$red: #D35D47 !default; +$orange: #fd7e14 !default; +$yellow: #D38F47 !default; +$green: #87C041 !default; +$teal: #20c997 !default; +$cyan: #0dcaf0 !default; + + @import "../node_modules/bootstrap/scss/variables"; $text-muted: $gray-500; diff --git a/warpgate-admin/app/src/vite-env.d.ts b/warpgate-web/src/vite-env.d.ts similarity index 100% rename from warpgate-admin/app/src/vite-env.d.ts rename to warpgate-web/src/vite-env.d.ts diff --git a/warpgate-admin/app/svelte.config.js b/warpgate-web/svelte.config.js similarity index 74% rename from warpgate-admin/app/svelte.config.js rename to warpgate-web/svelte.config.js index 072e005..d4d74e6 100644 --- a/warpgate-admin/app/svelte.config.js +++ b/warpgate-web/svelte.config.js @@ -1,6 +1,7 @@ import sveltePreprocess from 'svelte-preprocess' -export default { +/** @type {import('@sveltejs/kit').Config} */ +const config = { compilerOptions: { enableSourcemap: true, }, @@ -11,3 +12,5 @@ export default { prebundleSvelteLibraries: true, }, } + +export default config diff --git a/warpgate-admin/app/tsconfig.json b/warpgate-web/tsconfig.json similarity index 95% rename from warpgate-admin/app/tsconfig.json rename to warpgate-web/tsconfig.json index 6ad341a..d5ecdc0 100644 --- a/warpgate-admin/app/tsconfig.json +++ b/warpgate-web/tsconfig.json @@ -27,11 +27,13 @@ "include": [ "src/**/*.d.ts", "src/**/*.ts", + "src/*.ts", "src/**/*.js", "src/**/*.svelte" ], "exclude": [ "node_modules/@types/node/**", + "src/*/lib/api-client", ], "references": [ { diff --git a/warpgate-admin/app/tsconfig.node.json b/warpgate-web/tsconfig.node.json similarity index 100% rename from warpgate-admin/app/tsconfig.node.json rename to warpgate-web/tsconfig.node.json diff --git a/warpgate-admin/app/vite.config.ts b/warpgate-web/vite.config.ts similarity index 60% rename from warpgate-admin/app/vite.config.ts rename to warpgate-web/vite.config.ts index 47723c7..e538f71 100644 --- a/warpgate-admin/app/vite.config.ts +++ b/warpgate-web/vite.config.ts @@ -10,14 +10,24 @@ export default defineConfig({ tsconfigPaths(), (checker.default.default)({ typescript: true }), ], + base: '/@warpgate', build: { sourcemap: true, + manifest: true, commonjsOptions: { include: [ - 'api-client/dist/*.js', + 'src/gateway/lib/api-client/dist/*.js', + 'src/admin/lib/api-client/dist/*.js', '**/*.js', ], transformMixedEsModules: true, }, + rollupOptions: { + input: { + admin: 'src/admin/index.html', + gateway: 'src/gateway/index.html', + embed: 'src/embed/index.ts', + }, + }, }, }) diff --git a/warpgate-admin/app/yarn.lock b/warpgate-web/yarn.lock similarity index 100% rename from warpgate-admin/app/yarn.lock rename to warpgate-web/yarn.lock diff --git a/warpgate/Cargo.toml b/warpgate/Cargo.toml index 68d584e..978df6b 100644 --- a/warpgate/Cargo.toml +++ b/warpgate/Cargo.toml @@ -29,6 +29,7 @@ tracing = "0.1" tracing-subscriber = {version = "0.3", features = ["env-filter", "local-time"]} warpgate-admin = {version = "*", path = "../warpgate-admin"} warpgate-common = {version = "*", path = "../warpgate-common"} +warpgate-protocol-http = {version = "*", path = "../warpgate-protocol-http"} warpgate-protocol-ssh = {version = "*", path = "../warpgate-protocol-ssh"} [target.'cfg(target_os = "linux")'.dependencies] diff --git a/warpgate/src/commands/check.rs b/warpgate/src/commands/check.rs index f3a6a70..7ad3309 100644 --- a/warpgate/src/commands/check.rs +++ b/warpgate/src/commands/check.rs @@ -13,7 +13,7 @@ pub(crate) async fn command(cli: &crate::Cli) -> Result<()> { .context("Failed to parse SSH listen address")?; config .store - .web_admin + .http .listen .to_socket_addrs() .context("Failed to parse admin server listen address")?; diff --git a/warpgate/src/commands/run.rs b/warpgate/src/commands/run.rs index 8275c4e..3cc2137 100644 --- a/warpgate/src/commands/run.rs +++ b/warpgate/src/commands/run.rs @@ -6,6 +6,7 @@ use tracing::*; use warpgate_common::db::cleanup_db; use warpgate_common::logging::install_database_logger; use warpgate_common::{ProtocolServer, Services}; +use warpgate_protocol_http::HTTPProtocolServer; use warpgate_protocol_ssh::SSHProtocolServer; #[cfg(target_os = "linux")] @@ -20,7 +21,6 @@ pub(crate) async fn command(cli: &crate::Cli) -> Result<()> { install_database_logger(services.db.clone()); - let mut other_futures = futures::stream::FuturesUnordered::new(); let mut protocol_futures = futures::stream::FuturesUnordered::new(); protocol_futures.push( @@ -35,20 +35,18 @@ pub(crate) async fn command(cli: &crate::Cli) -> Result<()> { ), ); - if config.store.web_admin.enable { - let admin = warpgate_admin::AdminServer::new(&services); - let admin_future = admin.run( - config - .store - .web_admin - .listen - .to_socket_addrs()? - .next() - .ok_or_else(|| { - anyhow::anyhow!("Failed to resolve the listen address for the admin server") - })?, + if config.store.http.enable { + protocol_futures.push( + HTTPProtocolServer::new(&services).await?.run( + config + .store + .http + .listen + .to_socket_addrs()? + .next() + .ok_or_else(|| anyhow::anyhow!("Failed to resolve the listen address"))?, + ), ); - other_futures.push(admin_future); } tokio::spawn({ @@ -70,10 +68,10 @@ pub(crate) async fn command(cli: &crate::Cli) -> Result<()> { info!("--------------------------------------------"); info!("Warpgate is now running."); info!("Accepting SSH connections on {}", config.store.ssh.listen); - if config.store.web_admin.enable { + if config.store.http.enable { info!( - "Access admin UI on https://{}", - config.store.web_admin.listen + "Accepting HTTP connections on https://{}", + config.store.http.listen ); } info!("--------------------------------------------"); @@ -118,16 +116,6 @@ pub(crate) async fn command(cli: &crate::Cli) -> Result<()> { _ => (), } } - result = other_futures.next(), if !other_futures.is_empty() => { - match result { - Some(Err(error)) => { - error!(?error, "Error"); - std::process::exit(1); - }, - None => break, - _ => (), - } - } } } diff --git a/warpgate/src/commands/setup.rs b/warpgate/src/commands/setup.rs index 830ddbb..7a4fcb6 100644 --- a/warpgate/src/commands/setup.rs +++ b/warpgate/src/commands/setup.rs @@ -9,8 +9,8 @@ use tracing::*; use warpgate_common::helpers::fs::{secure_directory, secure_file}; use warpgate_common::helpers::hash::hash_password; use warpgate_common::{ - Role, SSHConfig, Secret, Services, Target, TargetWebAdminOptions, User, UserAuthCredential, - WarpgateConfigStore, WebAdminConfig, + HTTPConfig, Role, SSHConfig, Secret, Services, Target, TargetOptions, TargetWebAdminOptions, + User, UserAuthCredential, WarpgateConfigStore, }; pub(crate) async fn command(cli: &crate::Cli) -> Result<()> { @@ -80,27 +80,26 @@ pub(crate) async fn command(cli: &crate::Cli) -> Result<()> { // --- - store.web_admin.listen = dialoguer::Input::with_theme(&theme) - .default(WebAdminConfig::default().listen) - .with_prompt("Endpoint to expose admin web interface on") + store.http.listen = dialoguer::Input::with_theme(&theme) + .default(HTTPConfig::default().listen) + .with_prompt("Endpoint to listen for HTTP connections on") .interact_text()?; - if store.web_admin.enable { + if store.http.enable { store.targets.push(Target { name: "web-admin".to_owned(), allow_roles: vec!["warpgate:admin".to_owned()], - ssh: None, - web_admin: Some(TargetWebAdminOptions {}), + options: TargetOptions::WebAdmin(TargetWebAdminOptions {}), }); } - store.web_admin.certificate = PathBuf::from(&data_path) - .join("web-admin.certificate.pem") + store.http.certificate = PathBuf::from(&data_path) + .join("tls.certificate.pem") .to_string_lossy() .to_string(); - store.web_admin.key = PathBuf::from(&data_path) - .join("web-admin.key.pem") + store.http.key = PathBuf::from(&data_path) + .join("tls.key.pem") .to_string_lossy() .to_string(); @@ -160,8 +159,8 @@ pub(crate) async fn command(cli: &crate::Cli) -> Result<()> { let certificate_path = config .paths_relative_to - .join(&config.store.web_admin.certificate); - let key_path = config.paths_relative_to.join(&config.store.web_admin.key); + .join(&config.store.http.certificate); + let key_path = config.paths_relative_to.join(&config.store.http.key); std::fs::write(&certificate_path, cert.serialize_pem()?)?; std::fs::write(&key_path, cert.serialize_private_key_pem())?; secure_file(&certificate_path)?;