mirror of
https://github.com/warp-tech/warpgate.git
synced 2024-09-20 06:46:17 +08:00
HTTP targets support (fixes #116)
This commit is contained in:
parent
40b1083c39
commit
6342fcb3ed
|
@ -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}"
|
||||
|
|
2
.github/dependabot.yml
vendored
2
.github/dependabot.yml
vendored
|
@ -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:
|
||||
|
|
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
|
@ -35,7 +35,7 @@ jobs:
|
|||
|
||||
- name: Build admin UI
|
||||
run: |
|
||||
just yarn openapi-client
|
||||
just openapi
|
||||
just yarn build
|
||||
|
||||
- name: Build
|
||||
|
|
185
Cargo.lock
generated
185
Cargo.lock
generated
|
@ -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"
|
||||
|
|
|
@ -5,7 +5,9 @@ members = [
|
|||
"warpgate-common",
|
||||
"warpgate-db-migrations",
|
||||
"warpgate-db-entities",
|
||||
"warpgate-protocol-http",
|
||||
"warpgate-protocol-ssh",
|
||||
"warpgate-web",
|
||||
]
|
||||
default-members = ["warpgate"]
|
||||
|
||||
|
|
|
@ -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`)
|
||||
|
||||
|
|
10
justfile
10
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
|
||||
|
|
|
@ -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"}
|
||||
|
|
|
@ -1,86 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { api } from 'lib/api'
|
||||
import { authenticatedUsername } from 'lib/store'
|
||||
import { replace } from 'svelte-spa-router'
|
||||
|
||||
import { Alert, Button, FormGroup } from 'sveltestrap'
|
||||
let error: Error|null = null
|
||||
let username = ''
|
||||
let password = ''
|
||||
let incorrectCredentials = false
|
||||
|
||||
async function login (event?: MouseEvent) {
|
||||
event?.preventDefault()
|
||||
error = null
|
||||
incorrectCredentials = false
|
||||
try {
|
||||
await api.login({
|
||||
loginRequest: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
if (err.status === 401) {
|
||||
incorrectCredentials = true
|
||||
} else {
|
||||
error = err
|
||||
}
|
||||
return
|
||||
}
|
||||
const info = await api.getInfo()
|
||||
authenticatedUsername.set(info.username!)
|
||||
replace('/')
|
||||
}
|
||||
|
||||
function onInputKey (event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
login()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form class="mt-5 row" autocomplete="on">
|
||||
<div class="col-12 col-md-3"></div>
|
||||
<form class="col-12 col-md-6">
|
||||
<div class="page-summary-bar">
|
||||
<h1>Welcome</h1>
|
||||
</div>
|
||||
|
||||
<FormGroup floating label="Username">
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input
|
||||
bind:value={username}
|
||||
on:keypress={onInputKey}
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
class="form-control"
|
||||
autofocus />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup floating label="Password">
|
||||
<input
|
||||
bind:value={password}
|
||||
on:keypress={onInputKey}
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="form-control" />
|
||||
</FormGroup>
|
||||
|
||||
<Button
|
||||
outline
|
||||
type="submit"
|
||||
on:click={login}
|
||||
>Login</Button>
|
||||
|
||||
{#if incorrectCredentials}
|
||||
<Alert color="danger">Incorrect credentials</Alert>
|
||||
{/if}
|
||||
{#if error}
|
||||
<Alert color="danger">{error}</Alert>
|
||||
{/if}
|
||||
|
||||
</form>
|
||||
<div class="col-12 col-md-3"></div>
|
||||
</form>
|
|
@ -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'
|
|
@ -1,3 +0,0 @@
|
|||
import { writable } from 'svelte/store'
|
||||
|
||||
export const authenticatedUsername = writable<string|null>(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
|
|
@ -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<Mutex<dyn ConfigProvider + Send>>>,
|
||||
body: Json<LoginRequest>,
|
||||
) -> ApiResult<LoginResponse> {
|
||||
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<LogoutResponse> {
|
||||
session.clear();
|
||||
Ok(LogoutResponse::Success)
|
||||
}
|
||||
}
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(ApiResponse)]
|
||||
enum InstanceInfoResponse {
|
||||
#[oai(status = 200)]
|
||||
Ok(Json<Info>),
|
||||
}
|
||||
|
||||
#[OpenApi]
|
||||
impl Api {
|
||||
#[oai(path = "/info", method = "get", operation_id = "get_info")]
|
||||
async fn api_get_info(&self, session: &Session) -> ApiResult<InstanceInfoResponse> {
|
||||
Ok(InstanceInfoResponse::Ok(Json(Info {
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
username: session.get::<String>("username"),
|
||||
})))
|
||||
}
|
||||
}
|
|
@ -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,9 +27,7 @@ impl Api {
|
|||
&self,
|
||||
db: Data<&Arc<Mutex<DatabaseConnection>>>,
|
||||
id: Path<Uuid>,
|
||||
session: &Session,
|
||||
) -> ApiResult<DeleteSSHKnownHostResponse> {
|
||||
authorized(session, || async move {
|
||||
) -> poem::Result<DeleteSSHKnownHostResponse> {
|
||||
use warpgate_db_entities::KnownHost;
|
||||
let db = db.lock().await;
|
||||
|
||||
|
@ -50,7 +46,5 @@ impl Api {
|
|||
}
|
||||
None => Ok(DeleteSSHKnownHostResponse::NotFound),
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,9 +24,7 @@ impl Api {
|
|||
async fn api_ssh_get_all_known_hosts(
|
||||
&self,
|
||||
db: Data<&Arc<Mutex<DatabaseConnection>>>,
|
||||
session: &Session,
|
||||
) -> ApiResult<GetSSHKnownHostsResponse> {
|
||||
authorized(session, || async move {
|
||||
) -> poem::Result<GetSSHKnownHostsResponse> {
|
||||
use warpgate_db_entities::KnownHost;
|
||||
|
||||
let db = db.lock().await;
|
||||
|
@ -37,7 +33,5 @@ impl Api {
|
|||
.await
|
||||
.map_err(poem::error::InternalServerError)?;
|
||||
Ok(GetSSHKnownHostsResponse::Ok(Json(hosts)))
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,9 +33,7 @@ impl Api {
|
|||
&self,
|
||||
db: Data<&Arc<Mutex<DatabaseConnection>>>,
|
||||
body: Json<GetLogsRequest>,
|
||||
session: &Session,
|
||||
) -> ApiResult<GetLogsResponse> {
|
||||
authorized(session, || async move {
|
||||
) -> poem::Result<GetLogsResponse> {
|
||||
use warpgate_db_entities::LogEntry;
|
||||
|
||||
let db = db.lock().await;
|
||||
|
@ -76,7 +72,5 @@ impl Api {
|
|||
.map(Into::into)
|
||||
.collect::<Vec<LogEntry::Model>>();
|
||||
Ok(GetLogsResponse::Ok(Json(logs)))
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,9 +39,7 @@ impl Api {
|
|||
&self,
|
||||
db: Data<&Arc<Mutex<DatabaseConnection>>>,
|
||||
id: Path<Uuid>,
|
||||
session: &Session,
|
||||
) -> ApiResult<GetRecordingResponse> {
|
||||
authorized(session, || async move {
|
||||
) -> poem::Result<GetRecordingResponse> {
|
||||
let db = db.lock().await;
|
||||
|
||||
let recording = Recording::Entity::find_by_id(id.0)
|
||||
|
@ -55,8 +51,6 @@ impl Api {
|
|||
Some(recording) => Ok(GetRecordingResponse::Ok(Json(recording))),
|
||||
None => Ok(GetRecordingResponse::NotFound),
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -65,9 +59,7 @@ pub async fn api_get_recording_cast(
|
|||
db: Data<&Arc<Mutex<DatabaseConnection>>>,
|
||||
recordings: Data<&Arc<Mutex<SessionRecordings>>>,
|
||||
id: poem::web::Path<Uuid>,
|
||||
session: &Session,
|
||||
) -> ApiResult<String> {
|
||||
authorized(session, || async move {
|
||||
) -> poem::Result<String> {
|
||||
let db = db.lock().await;
|
||||
|
||||
let recording = Recording::Entity::find_by_id(id.0)
|
||||
|
@ -119,8 +111,6 @@ pub async fn api_get_recording_cast(
|
|||
);
|
||||
|
||||
Ok(response.join("\n"))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[handler]
|
||||
|
@ -128,9 +118,7 @@ pub async fn api_get_recording_tcpdump(
|
|||
db: Data<&Arc<Mutex<DatabaseConnection>>>,
|
||||
recordings: Data<&Arc<Mutex<SessionRecordings>>>,
|
||||
id: poem::web::Path<Uuid>,
|
||||
session: &Session,
|
||||
) -> ApiResult<Bytes> {
|
||||
authorized(session, || async move {
|
||||
) -> poem::Result<Bytes> {
|
||||
let db = db.lock().await;
|
||||
|
||||
let recording = Recording::Entity::find_by_id(id.0)
|
||||
|
@ -156,8 +144,6 @@ pub async fn api_get_recording_tcpdump(
|
|||
let content = std::fs::read(path).map_err(InternalServerError)?;
|
||||
|
||||
Ok(Bytes::from(content))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[handler]
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
use crate::helpers::{authorized, ApiResult};
|
||||
use poem::web::Data;
|
||||
use poem_openapi::param::Path;
|
||||
use poem_openapi::payload::Json;
|
||||
|
@ -42,9 +41,7 @@ impl Api {
|
|||
&self,
|
||||
db: Data<&Arc<Mutex<DatabaseConnection>>>,
|
||||
id: Path<Uuid>,
|
||||
session: &poem::session::Session,
|
||||
) -> ApiResult<GetSessionResponse> {
|
||||
authorized(session, || async move {
|
||||
) -> poem::Result<GetSessionResponse> {
|
||||
let db = db.lock().await;
|
||||
|
||||
let session = Session::Entity::find_by_id(id.0)
|
||||
|
@ -56,8 +53,6 @@ impl Api {
|
|||
Some(session) => Ok(GetSessionResponse::Ok(Json(session.into()))),
|
||||
None => Ok(GetSessionResponse::NotFound),
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[oai(
|
||||
|
@ -69,9 +64,7 @@ impl Api {
|
|||
&self,
|
||||
db: Data<&Arc<Mutex<DatabaseConnection>>>,
|
||||
id: Path<Uuid>,
|
||||
session: &poem::session::Session,
|
||||
) -> ApiResult<GetSessionRecordingsResponse> {
|
||||
authorized(session, || async move {
|
||||
) -> poem::Result<GetSessionRecordingsResponse> {
|
||||
let db = db.lock().await;
|
||||
let recordings: Vec<Recording::Model> = Recording::Entity::find()
|
||||
.order_by_desc(Recording::Column::Started)
|
||||
|
@ -80,8 +73,6 @@ impl Api {
|
|||
.await
|
||||
.map_err(poem::error::InternalServerError)?;
|
||||
Ok(GetSessionRecordingsResponse::Ok(Json(recordings)))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[oai(
|
||||
|
@ -93,9 +84,7 @@ impl Api {
|
|||
&self,
|
||||
state: Data<&Arc<Mutex<State>>>,
|
||||
id: Path<Uuid>,
|
||||
session: &poem::session::Session,
|
||||
) -> ApiResult<CloseSessionResponse> {
|
||||
authorized(session, || async move {
|
||||
) -> poem::Result<CloseSessionResponse> {
|
||||
let state = state.lock().await;
|
||||
|
||||
if let Some(s) = state.sessions.get(&id) {
|
||||
|
@ -105,7 +94,5 @@ impl Api {
|
|||
} else {
|
||||
Ok(CloseSessionResponse::NotFound)
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,9 +26,7 @@ impl Api {
|
|||
async fn api_get_all_sessions(
|
||||
&self,
|
||||
db: Data<&Arc<Mutex<DatabaseConnection>>>,
|
||||
session: &Session,
|
||||
) -> ApiResult<GetSessionsResponse> {
|
||||
authorized(session, || async move {
|
||||
) -> poem::Result<GetSessionsResponse> {
|
||||
use warpgate_db_entities::Session;
|
||||
|
||||
let db = db.lock().await;
|
||||
|
@ -44,8 +40,6 @@ impl Api {
|
|||
.map(Into::into)
|
||||
.collect::<Vec<SessionSnapshot>>();
|
||||
Ok(GetSessionsResponse::Ok(Json(sessions)))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[oai(
|
||||
|
@ -56,9 +50,7 @@ impl Api {
|
|||
async fn api_close_all_sessions(
|
||||
&self,
|
||||
state: Data<&Arc<Mutex<State>>>,
|
||||
session: &Session,
|
||||
) -> ApiResult<CloseAllSessionsResponse> {
|
||||
authorized(session, || async move {
|
||||
) -> poem::Result<CloseAllSessionsResponse> {
|
||||
let state = state.lock().await;
|
||||
|
||||
for s in state.sessions.values() {
|
||||
|
@ -67,7 +59,5 @@ impl Api {
|
|||
}
|
||||
|
||||
Ok(CloseAllSessionsResponse::Ok)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,9 +31,7 @@ impl Api {
|
|||
async fn api_ssh_get_own_keys(
|
||||
&self,
|
||||
config: Data<&Arc<Mutex<WarpgateConfig>>>,
|
||||
session: &Session,
|
||||
) -> ApiResult<GetSSHOwnKeysResponse> {
|
||||
authorized(session, || async move {
|
||||
) -> poem::Result<GetSSHOwnKeysResponse> {
|
||||
let config = config.lock().await;
|
||||
let keys = warpgate_protocol_ssh::load_client_keys(&config)
|
||||
.map_err(poem::error::InternalServerError)?;
|
||||
|
@ -48,7 +44,5 @@ impl Api {
|
|||
})
|
||||
.collect();
|
||||
Ok(GetSSHOwnKeysResponse::Ok(Json(keys)))
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Mutex<dyn ConfigProvider + Send>>>,
|
||||
session: &Session,
|
||||
) -> ApiResult<GetTargetsResponse> {
|
||||
authorized(session, || async move {
|
||||
) -> poem::Result<GetTargetsResponse> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,9 +28,7 @@ impl Api {
|
|||
&self,
|
||||
db: Data<&Arc<Mutex<DatabaseConnection>>>,
|
||||
id: Path<Uuid>,
|
||||
session: &Session,
|
||||
) -> ApiResult<DeleteTicketResponse> {
|
||||
authorized(session, || async move {
|
||||
) -> poem::Result<DeleteTicketResponse> {
|
||||
use warpgate_db_entities::Ticket;
|
||||
let db = db.lock().await;
|
||||
|
||||
|
@ -51,7 +47,5 @@ impl Api {
|
|||
}
|
||||
None => Ok(DeleteTicketResponse::NotFound),
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,9 +45,7 @@ impl Api {
|
|||
async fn api_get_all_tickets(
|
||||
&self,
|
||||
db: Data<&Arc<Mutex<DatabaseConnection>>>,
|
||||
session: &Session,
|
||||
) -> ApiResult<GetTicketsResponse> {
|
||||
authorized(session, || async move {
|
||||
) -> poem::Result<GetTicketsResponse> {
|
||||
use warpgate_db_entities::Ticket;
|
||||
|
||||
let db = db.lock().await;
|
||||
|
@ -62,8 +58,6 @@ impl Api {
|
|||
.map(Into::into)
|
||||
.collect::<Vec<Ticket::Model>>();
|
||||
Ok(GetTicketsResponse::Ok(Json(tickets)))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[oai(path = "/tickets", method = "post", operation_id = "create_ticket")]
|
||||
|
@ -71,9 +65,7 @@ impl Api {
|
|||
&self,
|
||||
db: Data<&Arc<Mutex<DatabaseConnection>>>,
|
||||
body: Json<CreateTicketRequest>,
|
||||
session: &Session,
|
||||
) -> ApiResult<CreateTicketResponse> {
|
||||
authorized(session, || async move {
|
||||
) -> poem::Result<CreateTicketResponse> {
|
||||
use warpgate_db_entities::Ticket;
|
||||
|
||||
if body.username.is_empty() {
|
||||
|
@ -100,7 +92,5 @@ impl Api {
|
|||
secret: secret.expose_secret().to_string(),
|
||||
ticket,
|
||||
})))
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Mutex<dyn ConfigProvider + Send>>>,
|
||||
session: &Session,
|
||||
) -> ApiResult<GetUsersResponse> {
|
||||
authorized(session, || async move {
|
||||
) -> poem::Result<GetUsersResponse> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,28 +0,0 @@
|
|||
use poem::http::StatusCode;
|
||||
use poem::session::Session;
|
||||
|
||||
pub type ApiResult<T> = poem::Result<T>;
|
||||
|
||||
pub trait SessionExt {
|
||||
fn is_authorized(&self) -> bool;
|
||||
}
|
||||
|
||||
impl SessionExt for Session {
|
||||
fn is_authorized(&self) -> bool {
|
||||
self.get::<String>("username").is_some()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn authorized<FN, FT, R>(session: &Session, f: FN) -> ApiResult<R>
|
||||
where
|
||||
FN: FnOnce() -> FT,
|
||||
FT: futures::Future<Output = ApiResult<R>>,
|
||||
{
|
||||
if !session.is_authorized() {
|
||||
return Err(poem::Error::from_string(
|
||||
"Unauthorized",
|
||||
StatusCode::UNAUTHORIZED,
|
||||
));
|
||||
}
|
||||
f().await
|
||||
}
|
|
@ -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 struct AdminServer {
|
||||
services: Services,
|
||||
}
|
||||
|
||||
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();
|
||||
pub fn admin_api_app(services: &Services) -> impl IntoEndpoint {
|
||||
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",
|
||||
crate::api::get(),
|
||||
"Warpgate Web Admin",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
)
|
||||
.server("/api");
|
||||
.server("/@warpgate/admin/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 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();
|
||||
|
||||
let app = Route::new()
|
||||
.nest("/api/swagger", ui)
|
||||
.nest("/api", api_service)
|
||||
.nest("/api/openapi.json", spec)
|
||||
.nest_no_strip("/assets", EmbeddedFilesEndpoint::<Assets>::new())
|
||||
.at("/", EmbeddedFileEndpoint::<Assets>::new("index.html"))
|
||||
Route::new()
|
||||
.nest("", api_service)
|
||||
.nest("/swagger", ui)
|
||||
.nest("/openapi.json", spec)
|
||||
.at(
|
||||
"/api/recordings/:id/cast",
|
||||
"/recordings/:id/cast",
|
||||
crate::api::recordings_detail::api_get_recording_cast,
|
||||
)
|
||||
.at(
|
||||
"/api/recordings/:id/stream",
|
||||
"/recordings/:id/stream",
|
||||
crate::api::recordings_detail::api_get_recording_stream,
|
||||
)
|
||||
.at(
|
||||
"/api/recordings/:id/tcpdump",
|
||||
"/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")
|
||||
}
|
||||
.data(db)
|
||||
.data(config_provider)
|
||||
.data(state)
|
||||
.data(recordings)
|
||||
.data(config)
|
||||
}
|
||||
|
|
10
warpgate-admin/src/main.rs
Normal file
10
warpgate-admin/src/main.rs
Normal file
|
@ -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());
|
||||
}
|
|
@ -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<String> {
|
|||
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<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ssh: Option<TargetSSHOptions>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub web_admin: Option<TargetWebAdminOptions>,
|
||||
#[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<String>,
|
||||
|
||||
#[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(),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
} => verify_password_hash(
|
||||
client_password.expose_secret(),
|
||||
user_password_hash.expose_secret(),
|
||||
) {
|
||||
Ok(true) => {
|
||||
valid_credentials.push(credential);
|
||||
break;
|
||||
}
|
||||
Ok(false) => continue,
|
||||
Err(e) => {
|
||||
)
|
||||
.unwrap_or_else(|e| {
|
||||
error!(
|
||||
username = &user.username[..],
|
||||
"Error verifying password hash: {}", e
|
||||
);
|
||||
continue;
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -13,12 +13,12 @@ use warpgate_db_entities::Ticket;
|
|||
|
||||
pub enum AuthResult {
|
||||
Accepted { username: String },
|
||||
OTPNeeded,
|
||||
OtpNeeded,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
pub enum AuthCredential {
|
||||
OTP(Secret<String>),
|
||||
Otp(Secret<String>),
|
||||
Password(Secret<String>),
|
||||
PublicKey {
|
||||
kind: String,
|
||||
|
|
|
@ -14,6 +14,7 @@ pub struct SessionSnapshot {
|
|||
pub started: DateTime<Utc>,
|
||||
pub ended: Option<DateTime<Utc>>,
|
||||
pub ticket_id: Option<Uuid>,
|
||||
pub protocol: String,
|
||||
}
|
||||
|
||||
impl From<Session::Model> for SessionSnapshot {
|
||||
|
@ -27,6 +28,7 @@ impl From<Session::Model> for SessionSnapshot {
|
|||
started: model.started,
|
||||
ended: model.ended,
|
||||
ticket_id: model.ticket_id,
|
||||
protocol: model.protocol,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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::*;
|
||||
|
|
|
@ -35,6 +35,10 @@ impl WarpgateServerHandle {
|
|||
self.id
|
||||
}
|
||||
|
||||
pub fn session_state(&self) -> &Arc<Mutex<SessionState>> {
|
||||
&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());
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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<Mutex<SessionState>>,
|
||||
) -> Result<WarpgateServerHandle> {
|
||||
) -> Result<Arc<Mutex<WarpgateServerHandle>>> {
|
||||
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<SocketAddr>,
|
||||
pub username: Option<String>,
|
||||
pub target: Option<Target>,
|
||||
pub handle: Box<dyn SessionHandle + Send>,
|
||||
}
|
||||
|
||||
impl SessionState {
|
||||
pub fn new(remote_address: SocketAddr, handle: Box<dyn SessionHandle + Send>) -> Self {
|
||||
pub fn new(remote_address: Option<SocketAddr>, handle: Box<dyn SessionHandle + Send>) -> Self {
|
||||
SessionState {
|
||||
remote_address,
|
||||
username: None,
|
||||
|
|
48
warpgate-common/src/try_macro.rs
Normal file
48
warpgate-common/src/try_macro.rs
Normal file
|
@ -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::<i32>().unwrap_err().to_string());
|
||||
caught = true;
|
||||
});
|
||||
assert!(caught);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_success() {
|
||||
try_block!({
|
||||
let _: u32 = "123".parse()?;
|
||||
} catch (_e: anyhow::Error) {
|
||||
panic!();
|
||||
});
|
||||
}
|
|
@ -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>(T);
|
||||
|
||||
impl Secret<String> {
|
||||
pub fn random() -> Self {
|
||||
Secret::new(HEXLOWER.encode(&Bytes::from_iter(get_crypto_rng().gen::<[u8; 32]>())))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Secret<T> {
|
||||
pub const fn new(v: T) -> Self {
|
||||
Self(v)
|
||||
|
@ -17,6 +29,12 @@ impl<T> Secret<T> {
|
|||
}
|
||||
}
|
||||
|
||||
impl<T> From<T> for Secret<T> {
|
||||
fn from(v: T) -> Self {
|
||||
Self::new(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de, T> Deserialize<'de> for Secret<T>
|
||||
where
|
||||
T: Deserialize<'de>,
|
||||
|
|
|
@ -13,6 +13,7 @@ pub struct Model {
|
|||
pub started: DateTime<Utc>,
|
||||
pub ended: Option<DateTime<Utc>>,
|
||||
pub ticket_id: Option<Uuid>,
|
||||
pub protocol: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter)]
|
||||
|
|
|
@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
41
warpgate-db-migrations/src/m00006_add_session_protocol.rs
Normal file
41
warpgate-db-migrations/src/m00006_add_session_protocol.rs
Normal file
|
@ -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
|
||||
}
|
||||
}
|
29
warpgate-protocol-http/Cargo.toml
Normal file
29
warpgate-protocol-http/Cargo.toml
Normal file
|
@ -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"]}
|
112
warpgate-protocol-http/src/api/auth.rs
Normal file
112
warpgate-protocol-http/src/api/auth.rs
Normal file
|
@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<LoginFailureResponse>),
|
||||
}
|
||||
|
||||
#[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<Mutex<SessionMiddleware>>>,
|
||||
body: Json<LoginRequest>,
|
||||
) -> poem::Result<LoginResponse> {
|
||||
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<Mutex<SessionMiddleware>>>,
|
||||
) -> poem::Result<LogoutResponse> {
|
||||
session_middleware.lock().await.remove_session(session);
|
||||
session.clear();
|
||||
info!("Logged out");
|
||||
Ok(LogoutResponse::Success)
|
||||
}
|
||||
}
|
59
warpgate-protocol-http/src/api/info.rs
Normal file
59
warpgate-protocol-http/src/api/info.rs
Normal file
|
@ -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<String>,
|
||||
selected_target: Option<String>,
|
||||
ports: PortsInfo,
|
||||
}
|
||||
|
||||
#[derive(ApiResponse)]
|
||||
enum InstanceInfoResponse {
|
||||
#[oai(status = 200)]
|
||||
Ok(Json<Info>),
|
||||
}
|
||||
|
||||
#[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<InstanceInfoResponse> {
|
||||
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 }
|
||||
},
|
||||
})))
|
||||
}
|
||||
}
|
9
warpgate-protocol-http/src/api/mod.rs
Normal file
9
warpgate-protocol-http/src/api/mod.rs
Normal file
|
@ -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)
|
||||
}
|
77
warpgate-protocol-http/src/api/targets_list.rs
Normal file
77
warpgate-protocol-http/src/api/targets_list.rs
Normal file
|
@ -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<Vec<Target>>),
|
||||
}
|
||||
|
||||
#[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<GetTargetsResponse> {
|
||||
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::<Vec<_>>()
|
||||
.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(),
|
||||
)))
|
||||
}
|
||||
}
|
84
warpgate-protocol-http/src/catchall.rs
Normal file
84
warpgate-protocol-http/src/catchall.rs
Normal file
|
@ -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<String>,
|
||||
}
|
||||
|
||||
#[handler]
|
||||
pub async fn catchall_endpoint(
|
||||
req: &Request,
|
||||
ws: Option<WebSocket>,
|
||||
session: &Session,
|
||||
body: Body,
|
||||
username: Data<&SessionUsername>,
|
||||
services: Data<&Services>,
|
||||
server_handle: Option<Data<&Arc<Mutex<WarpgateServerHandle>>>>,
|
||||
) -> poem::Result<Response> {
|
||||
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(),
|
||||
})
|
||||
}
|
131
warpgate-protocol-http/src/common.rs
Normal file
131
warpgate-protocol-http/src/common.rs
Normal file
|
@ -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<String>;
|
||||
fn set_target_name(&self, target_name: String);
|
||||
fn is_authenticated(&self) -> bool;
|
||||
fn get_username(&self) -> Option<String>;
|
||||
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<String> {
|
||||
self.get::<String>(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<String> {
|
||||
self.get::<String>(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<bool> {
|
||||
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: Endpoint + 'static>(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: Endpoint + 'static>(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: Endpoint + 'static>(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: Endpoint + 'static>(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()
|
||||
}
|
166
warpgate-protocol-http/src/lib.rs
Normal file
166
warpgate-protocol-http/src/lib.rs
Normal file
|
@ -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<Self> {
|
||||
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::<Assets>::new())
|
||||
.nest(
|
||||
"/admin/api",
|
||||
endpoint_auth(endpoint_admin_auth(admin_api_app)),
|
||||
)
|
||||
.at(
|
||||
"/admin",
|
||||
page_auth(page_admin_auth(EmbeddedFileEndpoint::<Assets>::new(
|
||||
"src/admin/index.html",
|
||||
))),
|
||||
)
|
||||
.at(
|
||||
"",
|
||||
EmbeddedFileEndpoint::<Assets>::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<Mutex<SessionMiddleware>>>::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")
|
||||
}
|
||||
}
|
32
warpgate-protocol-http/src/logging.rs
Normal file
32
warpgate-protocol-http/src/logging.rs
Normal file
|
@ -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<Span> {
|
||||
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");
|
||||
}
|
||||
}
|
16
warpgate-protocol-http/src/main.rs
Normal file
16
warpgate-protocol-http/src/main.rs
Normal file
|
@ -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());
|
||||
}
|
402
warpgate-protocol-http/src/proxy.rs
Normal file
402
warpgate-protocol-http/src/proxy.rs
Normal file
|
@ -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<B> SomeResponse for http::Response<B> {
|
||||
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<HeaderName> = {
|
||||
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<R: SomeResponse>(
|
||||
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<B: SomeRequestBuilder>(mut req: B, options: &TargetHTTPOptions) -> Result<B> {
|
||||
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<B: SomeRequestBuilder>(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::<Vec<_>>()
|
||||
.join("; "),
|
||||
);
|
||||
}
|
||||
target
|
||||
}
|
||||
|
||||
pub async fn proxy_normal_request(
|
||||
req: &Request,
|
||||
body: Body,
|
||||
options: &TargetHTTPOptions,
|
||||
) -> poem::Result<Response> {
|
||||
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 type="module" src="/@warpgate/{}"></script>"#,
|
||||
script_manifest.file
|
||||
);
|
||||
for css_file in script_manifest.css.unwrap_or(vec![]) {
|
||||
inject += &format!(
|
||||
r#"<link rel="stylesheet" href="/@warpgate/{}" />"#,
|
||||
css_file
|
||||
);
|
||||
}
|
||||
|
||||
let before = "</head>";
|
||||
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<impl IntoResponse> {
|
||||
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<impl IntoResponse> {
|
||||
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)
|
||||
}
|
175
warpgate-protocol-http/src/session.rs
Normal file
175
warpgate-protocol-http/src/session.rs
Normal file
|
@ -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<Mutex<Box<dyn SessionStorage>>>);
|
||||
|
||||
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<Option<BTreeMap<String, Value>>> {
|
||||
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<String, Value>,
|
||||
expires: Option<Duration>,
|
||||
) -> 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<SessionId, Arc<Mutex<WarpgateServerHandle>>>,
|
||||
session_timestamps: HashMap<SessionId, Instant>,
|
||||
this: Weak<Mutex<SessionMiddleware>>,
|
||||
}
|
||||
|
||||
static SESSION_ID_SESSION_KEY: &str = "session_id";
|
||||
static SESSION_ID_REQUEST_COUNTER: &str = "request_counter";
|
||||
|
||||
impl SessionMiddleware {
|
||||
pub fn new() -> Arc<Mutex<Self>> {
|
||||
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<Request> {
|
||||
let session: &Session = <_>::from_request_without_body(&req).await?;
|
||||
|
||||
let request_counter = session.get::<u64>(SESSION_ID_REQUEST_COUNTER).unwrap_or(0);
|
||||
session.set(SESSION_ID_REQUEST_COUNTER, request_counter + 1);
|
||||
|
||||
if let Some(session_id) = session.get::<SessionId>(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<WarpgateServerHandleFromRequest> {
|
||||
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<String> = 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<Arc<Mutex<WarpgateServerHandle>>> {
|
||||
session
|
||||
.get::<SessionId>(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::<SessionId>(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);
|
||||
}
|
||||
}
|
||||
}
|
64
warpgate-protocol-http/src/session_handle.rs
Normal file
64
warpgate-protocol-http/src/session_handle.rs
Normal file
|
@ -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<SessionHandleCommand>,
|
||||
}
|
||||
|
||||
impl HttpSessionHandle {
|
||||
pub fn new() -> (Self, mpsc::UnboundedReceiver<SessionHandleCommand>) {
|
||||
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<Mutex<WarpgateServerHandle>>);
|
||||
|
||||
impl std::ops::Deref for WarpgateServerHandleFromRequest {
|
||||
type Target = Arc<Mutex<WarpgateServerHandle>>;
|
||||
|
||||
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<Self> {
|
||||
let sm = Data::<&Arc<Mutex<SessionMiddleware>>>::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::<WarpgateServerHandle>()))?)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Arc<Mutex<WarpgateServerHandle>>> for WarpgateServerHandleFromRequest {
|
||||
fn from(handle: Arc<Mutex<WarpgateServerHandle>>) -> Self {
|
||||
WarpgateServerHandleFromRequest(handle)
|
||||
}
|
||||
}
|
|
@ -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,7 +370,9 @@ impl RemoteClient {
|
|||
return Err(ConnectionError::Aborted)
|
||||
}
|
||||
session = &mut fut_connect => {
|
||||
if let Err(error) = session {
|
||||
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),
|
||||
|
@ -374,9 +381,7 @@ impl RemoteClient {
|
|||
error!(error=?connection_error, "Connection error");
|
||||
return Err(connection_error);
|
||||
}
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let mut session = session.unwrap();
|
||||
};
|
||||
|
||||
let mut auth_result = false;
|
||||
match ssh_options.auth {
|
||||
|
|
|
@ -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()));
|
||||
};
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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<Mutex<WarpgateServerHandle>>,
|
||||
target: TargetSelection,
|
||||
traffic_recorders: HashMap<(String, u32), TrafficRecorder>,
|
||||
traffic_connection_recorders: HashMap<Uuid, ConnectionRecorder>,
|
||||
|
@ -88,10 +88,10 @@ impl ServerSession {
|
|||
pub async fn new(
|
||||
remote_address: SocketAddr,
|
||||
services: &Services,
|
||||
server_handle: WarpgateServerHandle,
|
||||
server_handle: Arc<Mutex<WarpgateServerHandle>>,
|
||||
mut session_handle_rx: UnboundedReceiver<SessionHandleCommand>,
|
||||
) -> Result<Arc<Mutex<Self>>> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
@ -154,3 +154,4 @@ overrides:
|
|||
ignorePatterns:
|
||||
- svelte.config.js
|
||||
- vite.config.ts
|
||||
- src/*/lib/api-client/**
|
11
warpgate-web/Cargo.toml
Normal file
11
warpgate-web/Cargo.toml
Normal file
|
@ -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"
|
|
@ -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",
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
@ -1,30 +1,24 @@
|
|||
<script lang="ts">
|
||||
import { faSignOut } from '@fortawesome/free-solid-svg-icons'
|
||||
import { api } from 'lib/api'
|
||||
import { authenticatedUsername } from 'lib/store'
|
||||
import { api } from 'gateway/lib/api'
|
||||
import { serverInfo, reloadServerInfo } from 'gateway/lib/store'
|
||||
import Fa from 'svelte-fa'
|
||||
|
||||
import logo from '../public/assets/logo.svg'
|
||||
import logo from '../../public/assets/logo.svg'
|
||||
|
||||
import Router, { link, push } from 'svelte-spa-router'
|
||||
import Router, { link } from 'svelte-spa-router'
|
||||
import active from 'svelte-spa-router/active'
|
||||
import { wrap } from 'svelte-spa-router/wrap'
|
||||
|
||||
let version = ''
|
||||
import { Spinner } from 'sveltestrap'
|
||||
|
||||
async function init () {
|
||||
const info = await api.getInfo()
|
||||
version = info.version
|
||||
authenticatedUsername.set(info.username ?? null)
|
||||
if (!info.username) {
|
||||
push('/login')
|
||||
}
|
||||
await reloadServerInfo()
|
||||
}
|
||||
|
||||
async function logout () {
|
||||
await api.logout()
|
||||
authenticatedUsername.set(null)
|
||||
push('/login')
|
||||
await reloadServerInfo()
|
||||
location.href = '/@warpgate'
|
||||
}
|
||||
|
||||
init()
|
||||
|
@ -33,9 +27,6 @@ const routes = {
|
|||
'/': wrap({
|
||||
asyncComponent: () => import('./Home.svelte'),
|
||||
}),
|
||||
'/login': wrap({
|
||||
asyncComponent: () => import('./Login.svelte'),
|
||||
}),
|
||||
'/sessions/:id': wrap({
|
||||
asyncComponent: () => import('./Session.svelte'),
|
||||
}),
|
||||
|
@ -60,21 +51,24 @@ const routes = {
|
|||
}
|
||||
</script>
|
||||
|
||||
{#await init()}
|
||||
<Spinner />
|
||||
{:then}
|
||||
<div class="app container">
|
||||
<header>
|
||||
<a use:link use:active href="/" class="d-flex">
|
||||
<a href="/@warpgate" class="d-flex">
|
||||
<img class="logo" src={logo} alt="Logo" />
|
||||
</a>
|
||||
{#if $authenticatedUsername}
|
||||
{#if $serverInfo?.username}
|
||||
<a use:link use:active href="/">Sessions</a>
|
||||
<a use:link use:active href="/targets">Targets</a>
|
||||
<a use:link use:active href="/tickets">Tickets</a>
|
||||
<a use:link use:active href="/ssh">SSH</a>
|
||||
<a use:link use:active href="/log">Log</a>
|
||||
{/if}
|
||||
{#if $authenticatedUsername}
|
||||
{#if $serverInfo?.username}
|
||||
<div class="username">
|
||||
<!-- {$authenticatedUsername} -->
|
||||
{$serverInfo?.username}
|
||||
</div>
|
||||
<button class="btn btn-link" on:click={logout}>
|
||||
<Fa icon={faSignOut} fw />
|
||||
|
@ -85,12 +79,13 @@ const routes = {
|
|||
<Router {routes}/>
|
||||
</main>
|
||||
<footer>
|
||||
{version}
|
||||
{$serverInfo?.version}
|
||||
</footer>
|
||||
</div>
|
||||
{/await}
|
||||
|
||||
<style lang="scss">
|
||||
@import "./vars";
|
||||
@import "../vars";
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
|
@ -130,11 +125,4 @@ const routes = {
|
|||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
padding: 10px 0;
|
||||
margin: 20px 0 10px;
|
||||
border-top: 1px solid rgba($body-color, .75);
|
||||
}
|
||||
</style>
|
|
@ -1,7 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { api, UserSnapshot, Target, TicketAndSecret } from 'lib/api'
|
||||
import { api, UserSnapshot, Target, TicketAndSecret } from 'admin/lib/api'
|
||||
import AsyncButton from 'common/AsyncButton.svelte'
|
||||
import { link } from 'svelte-spa-router'
|
||||
import { Alert, Button, FormGroup } from 'sveltestrap'
|
||||
import { Alert, FormGroup } from 'sveltestrap'
|
||||
import { firstBy } from 'thenby'
|
||||
|
||||
let error: Error|null = null
|
||||
|
@ -55,7 +56,7 @@ async function create () {
|
|||
The secret is only shown once - you won't be able to see it again.
|
||||
</Alert>
|
||||
|
||||
{#if selectedTarget?.ssh}
|
||||
{#if selectedTarget?.options.kind === 'TargetSSHOptions'}
|
||||
<h3>Connection instructions</h3>
|
||||
|
||||
<FormGroup floating label="SSH username">
|
||||
|
@ -101,8 +102,8 @@ async function create () {
|
|||
</FormGroup>
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
<AsyncButton
|
||||
outline
|
||||
on:click={create}
|
||||
>Create ticket</Button>
|
||||
click={create}
|
||||
>Create ticket</AsyncButton>
|
||||
{/if}
|
|
@ -1,14 +1,15 @@
|
|||
<script lang="ts">
|
||||
import Fa from 'svelte-fa'
|
||||
import { faCircleDot as iconActive } from '@fortawesome/free-regular-svg-icons'
|
||||
import { Spinner, Button } from 'sveltestrap'
|
||||
import { Spinner } from 'sveltestrap'
|
||||
import { onDestroy } from 'svelte'
|
||||
import { link } from 'svelte-spa-router'
|
||||
import { api, SessionSnapshot } from 'lib/api'
|
||||
import { api, SessionSnapshot } from 'admin/lib/api'
|
||||
import { derived, writable } from 'svelte/store'
|
||||
import { firstBy } from 'thenby'
|
||||
import moment from 'moment'
|
||||
import RelativeDate from 'RelativeDate.svelte'
|
||||
import RelativeDate from './RelativeDate.svelte'
|
||||
import AsyncButton from 'common/AsyncButton.svelte'
|
||||
|
||||
const sessions = writable<SessionSnapshot[]|null>(null)
|
||||
|
||||
|
@ -29,7 +30,6 @@
|
|||
return `${user} on ${target}`
|
||||
}
|
||||
|
||||
|
||||
let activeSessions = derived(sessions, s => s?.filter(x => !x.ended).length ?? 0)
|
||||
let sortedSessions = derived(sessions, s => s?.sort(
|
||||
firstBy<SessionSnapshot, boolean>(x => !!x.ended, 'asc')
|
||||
|
@ -40,15 +40,17 @@
|
|||
onDestroy(() => clearInterval(interval))
|
||||
</script>
|
||||
|
||||
{#if !sessions}
|
||||
{#if !$sessions}
|
||||
<Spinner />
|
||||
{:else}
|
||||
<div class="page-summary-bar">
|
||||
{#if $activeSessions }
|
||||
<h1>Sessions right now: {$activeSessions}</h1>
|
||||
<Button class="ms-auto" outline on:click={closeAllSesssions}>
|
||||
<div class="ms-auto">
|
||||
<AsyncButton outline click={closeAllSesssions}>
|
||||
Close all sessions
|
||||
</Button>
|
||||
</AsyncButton>
|
||||
</div>
|
||||
{:else}
|
||||
<h1>No active sessions</h1>
|
||||
{/if}
|
||||
|
@ -67,6 +69,7 @@
|
|||
<Fa icon={iconActive} fw />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="protocol text-muted me-2">{session.protocol}</div>
|
||||
<strong>
|
||||
{describeSession(session)}
|
||||
</strong>
|
||||
|
@ -101,6 +104,10 @@
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
.protocol {
|
||||
min-width: 3rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
opacity: .75;
|
||||
margin-left: 25px;
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import LogViewer from 'LogViewer.svelte'
|
||||
import LogViewer from './LogViewer.svelte'
|
||||
</script>
|
||||
|
||||
<div class="page-summary-bar">
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { api, LogEntry } from 'lib/api'
|
||||
import { api, LogEntry } from 'admin/lib/api'
|
||||
import { Alert } from 'sveltestrap'
|
||||
import { firstBy } from 'thenby'
|
||||
import IntersectionObserver from 'svelte-intersection-observer'
|
||||
|
@ -208,7 +208,7 @@ onDestroy(() => {
|
|||
|
||||
|
||||
<style lang="scss">
|
||||
@import "./vars";
|
||||
@import "../vars";
|
||||
|
||||
.table-wrapper {
|
||||
max-width: 100%;
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { api, Recording } from 'lib/api'
|
||||
import { api, Recording } from 'admin/lib/api'
|
||||
import { Alert, Spinner } from 'sveltestrap'
|
||||
import TerminalRecordingPlayer from 'player/TerminalRecordingPlayer.svelte'
|
||||
import TerminalRecordingPlayer from 'admin/player/TerminalRecordingPlayer.svelte'
|
||||
|
||||
export let params = { id: '' }
|
||||
|
||||
|
@ -13,7 +13,7 @@ async function load () {
|
|||
}
|
||||
|
||||
function getTCPDumpURL () {
|
||||
return `/api/recordings/${recording?.id}/tcpdump`
|
||||
return `/@warpgate/api/recordings/${recording?.id}/tcpdump`
|
||||
}
|
||||
|
||||
load().catch(e => {
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { timeAgo } from 'lib/time'
|
||||
import { timeAgo } from 'admin/lib/time'
|
||||
export let date: any
|
||||
</script>
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { api, SSHKey, SSHKnownHost } from 'lib/api'
|
||||
import { api, SSHKey, SSHKnownHost } from 'admin/lib/api'
|
||||
import { Alert } from 'sveltestrap'
|
||||
|
||||
let error: Error|undefined
|
|
@ -1,12 +1,13 @@
|
|||
<script lang="ts">
|
||||
import { api, SessionSnapshot, Recording } from 'lib/api'
|
||||
import { timeAgo } from 'lib/time'
|
||||
import { api, SessionSnapshot, Recording, TargetSSHOptions, TargetHTTPOptions } from 'admin/lib/api'
|
||||
import { timeAgo } from 'admin/lib/time'
|
||||
import AsyncButton from 'common/AsyncButton.svelte'
|
||||
import moment from 'moment'
|
||||
import { onDestroy } from 'svelte'
|
||||
import { link } from 'svelte-spa-router'
|
||||
import { Alert, Button, Spinner } from 'sveltestrap'
|
||||
import LogViewer from 'LogViewer.svelte'
|
||||
import RelativeDate from 'RelativeDate.svelte'
|
||||
import { Alert, Spinner } from 'sveltestrap'
|
||||
import LogViewer from './LogViewer.svelte'
|
||||
import RelativeDate from './RelativeDate.svelte'
|
||||
|
||||
export let params = { id: '' }
|
||||
|
||||
|
@ -25,7 +26,16 @@ async function close () {
|
|||
|
||||
function getTargetDescription () {
|
||||
if (session?.target) {
|
||||
return `${session.target.name} (${session.target.ssh?.host}:${session.target.ssh?.port})`
|
||||
let address = '<unknown>'
|
||||
if (session.target.options.kind === 'TargetSSHOptions') {
|
||||
const options = session.target.options as TargetSSHOptions
|
||||
address = `${options.host}:${options?.port}`
|
||||
}
|
||||
if (session.target.options.kind === 'TargetHTTPOptions') {
|
||||
const options = session.target.options as unknown as TargetHTTPOptions
|
||||
address = options.url
|
||||
}
|
||||
return `${session.target.name} (${address})`
|
||||
} else {
|
||||
return 'Not selected yet'
|
||||
}
|
||||
|
@ -72,9 +82,11 @@ onDestroy(() => clearInterval(interval))
|
|||
</div>
|
||||
</div>
|
||||
{#if !session.ended}
|
||||
<Button class="ms-auto" outline on:click={close}>
|
||||
<div class="ms-auto">
|
||||
<AsyncButton outline click={close}>
|
||||
Close now
|
||||
</Button>
|
||||
</AsyncButton>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { api, Target, UserSnapshot } from 'lib/api'
|
||||
import { getSSHUsername } from 'lib/ssh'
|
||||
import { api, Target, UserSnapshot } from 'admin/lib/api'
|
||||
import { getSSHUsername } from 'admin/lib/ssh'
|
||||
import { Alert, FormGroup, Modal, ModalBody, ModalHeader } from 'sveltestrap'
|
||||
|
||||
let error: Error|undefined
|
||||
|
@ -41,10 +41,10 @@ $: sshUsername = getSSHUsername(selectedUser, selectedTarget)
|
|||
{target.name}
|
||||
</strong>
|
||||
<small class="text-muted ms-auto">
|
||||
{#if target.ssh}
|
||||
{#if target.options.kind === 'TargetSSHOptions'}
|
||||
SSH
|
||||
{/if}
|
||||
{#if target.webAdmin}
|
||||
{#if target.options.kind === 'TargetWebAdminOptions'}
|
||||
This web admin interface
|
||||
{/if}
|
||||
</small>
|
||||
|
@ -58,16 +58,16 @@ $: sshUsername = getSSHUsername(selectedUser, selectedTarget)
|
|||
{selectedTarget?.name}
|
||||
</div>
|
||||
<div class="target-type-label">
|
||||
{#if selectedTarget?.ssh}
|
||||
{#if selectedTarget?.options.kind === 'TargetSSHOptions'}
|
||||
SSH target
|
||||
{/if}
|
||||
{#if selectedTarget?.webAdmin}
|
||||
{#if selectedTarget?.options.kind === 'TargetWebAdminOptions'}
|
||||
This web admin interface
|
||||
{/if}
|
||||
</div>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
{#if selectedTarget?.ssh}
|
||||
{#if selectedTarget?.options.kind === 'TargetSSHOptions'}
|
||||
<h3>Connection instructions</h3>
|
||||
{#if users}
|
||||
<FormGroup floating label="Select a user">
|
|
@ -1,8 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { api, Ticket } from 'lib/api'
|
||||
import { api, Ticket } from 'admin/lib/api'
|
||||
import { link } from 'svelte-spa-router'
|
||||
import { Alert } from 'sveltestrap'
|
||||
import RelativeDate from 'RelativeDate.svelte'
|
||||
import RelativeDate from './RelativeDate.svelte'
|
||||
|
||||
let error: Error|undefined
|
||||
let tickets: Ticket[]|undefined
|
|
@ -8,6 +8,6 @@
|
|||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<script type="module" src="/src/admin/index.ts"></script>
|
||||
</body>
|
||||
</html>
|
10
warpgate-web/src/admin/index.ts
Normal file
10
warpgate-web/src/admin/index.ts
Normal file
|
@ -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 { }
|
8
warpgate-web/src/admin/lib/api.ts
Normal file
8
warpgate-web/src/admin/lib/api.ts
Normal file
|
@ -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'
|
|
@ -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,13 +588,95 @@
|
|||
"type": "string"
|
||||
}
|
||||
},
|
||||
"ssh": {
|
||||
"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"
|
||||
},
|
||||
"web_admin": {
|
||||
"$ref": "#/components/schemas/TargetWebAdminOptions"
|
||||
{
|
||||
"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",
|
|
@ -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 @@
|
|||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import "../../node_modules/xterm/css/xterm.css";
|
||||
@import "../../../node_modules/xterm/css/xterm.css";
|
||||
|
||||
.root {
|
||||
border-radius: 5px;
|
34
warpgate-web/src/common/AsyncButton.svelte
Normal file
34
warpgate-web/src/common/AsyncButton.svelte
Normal file
|
@ -0,0 +1,34 @@
|
|||
<script lang="ts">
|
||||
import { Button, Spinner } from 'sveltestrap'
|
||||
import type { ButtonColor } from 'sveltestrap/src/Button'
|
||||
|
||||
export let click: CallableFunction
|
||||
export let color: ButtonColor = 'secondary'
|
||||
export let disabled = false
|
||||
export let outline = false
|
||||
export let type = 'submit'
|
||||
let busy = false
|
||||
|
||||
async function _click () {
|
||||
busy = true
|
||||
try {
|
||||
await click()
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<Button
|
||||
on:click={_click}
|
||||
outline={outline}
|
||||
color={color}
|
||||
type={type}
|
||||
disabled={disabled || busy}
|
||||
>
|
||||
<slot />
|
||||
{#if busy}
|
||||
<Spinner size="sm" />
|
||||
{/if}
|
||||
</Button>
|
161
warpgate-web/src/embed/EmbeddedUI.svelte
Normal file
161
warpgate-web/src/embed/EmbeddedUI.svelte
Normal file
|
@ -0,0 +1,161 @@
|
|||
<script lang="ts">
|
||||
import { api } from 'gateway/lib/api'
|
||||
import { onMount } from 'svelte'
|
||||
import logo from '../../public/assets/logo.svg'
|
||||
|
||||
let ready = false
|
||||
let menuVisible = false
|
||||
let dragging = false
|
||||
let savedPosition = { x: 0.1, y: 0.8 }
|
||||
let position = { x: 0.1, y: 0.8 }
|
||||
let dragStartCoords = { x: 0, y: 0 }
|
||||
|
||||
if (localStorage.warpgateMenuLocation) {
|
||||
position = JSON.parse(localStorage.warpgateMenuLocation)
|
||||
savedPosition = position
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
ready = true
|
||||
})
|
||||
|
||||
function drag (e: MouseEvent) {
|
||||
if (!dragging) {
|
||||
return
|
||||
}
|
||||
const { x, y } = dragStartCoords
|
||||
const { clientX, clientY } = e
|
||||
const dx = clientX - x
|
||||
const dy = clientY - y
|
||||
position = {
|
||||
x: Math.max(0, Math.min(1, savedPosition.x + dx / window.innerWidth)),
|
||||
y: Math.max(0, Math.min(1, savedPosition.y + dy / window.innerHeight)),
|
||||
}
|
||||
}
|
||||
|
||||
function startDragging (e: MouseEvent) {
|
||||
dragStartCoords = { x: e.clientX, y: e.clientY }
|
||||
dragging = true
|
||||
}
|
||||
|
||||
function stopDragging () {
|
||||
dragging = false
|
||||
savedPosition = position
|
||||
localStorage.warpgateMenuLocation = JSON.stringify(position)
|
||||
}
|
||||
|
||||
function goHome () {
|
||||
location.href = '/@warpgate'
|
||||
}
|
||||
|
||||
async function logout () {
|
||||
await api.logout()
|
||||
location.reload()
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
on:mousemove|passive={drag}
|
||||
on:mouseup={() => {
|
||||
menuVisible = false
|
||||
stopDragging()
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="embedded-ui"
|
||||
class:wg-hidden={!ready}
|
||||
style="left: {position.x * 100}%; top: {position.y * 100}%"
|
||||
>
|
||||
<button
|
||||
class="menu-toggle"
|
||||
on:mouseup|stopPropagation|preventDefault={() => {
|
||||
if (!dragging) {
|
||||
menuVisible = !menuVisible
|
||||
} else {
|
||||
stopDragging()
|
||||
}
|
||||
}}
|
||||
on:mousemove={e => {
|
||||
if (e.buttons && !dragging) {
|
||||
startDragging(e)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img class="logo" src={logo} alt="Warpgate" on:mousedown|preventDefault />
|
||||
</button>
|
||||
|
||||
{#if menuVisible}
|
||||
<div class="menu">
|
||||
<button on:mouseup={goHome}>Home</button>
|
||||
<button on:mouseup={logout}>Log out</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.embedded-ui {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
|
||||
color: #555;
|
||||
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
|
||||
&.wg-hidden button {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
> button.menu-toggle {
|
||||
transition: 0.5s ease-out opacity;
|
||||
opacity: 1;
|
||||
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
border-radius: 7px;
|
||||
border: 1px solid rgba(128, 128, 128, .25);
|
||||
background: rgba(255, 255, 255, .5);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(100% + 10px);
|
||||
|
||||
min-width: 200px;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
|
||||
border-radius: 7px;
|
||||
border: 1px solid rgba(128, 128, 128, .25);
|
||||
background: rgba(255, 255, 255, .5);
|
||||
backdrop-filter: blur(4px);
|
||||
|
||||
padding: 5px;
|
||||
|
||||
> button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 5px 10px;
|
||||
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
|
||||
color: #888;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #555;
|
||||
background: rgba(255, 255, 255, .25);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
23
warpgate-web/src/embed/index.ts
Normal file
23
warpgate-web/src/embed/index.ts
Normal file
|
@ -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,
|
||||
}))
|
77
warpgate-web/src/gateway/App.svelte
Normal file
77
warpgate-web/src/gateway/App.svelte
Normal file
|
@ -0,0 +1,77 @@
|
|||
<script lang="ts">
|
||||
import { faSignOut } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Alert, Spinner } from 'sveltestrap'
|
||||
import Fa from 'svelte-fa'
|
||||
|
||||
import { api } from 'gateway/lib/api'
|
||||
import { reloadServerInfo, serverInfo } from 'gateway/lib/store'
|
||||
import logo from '../../public/assets/logo.svg'
|
||||
import Login from './Login.svelte'
|
||||
import TargetList from './TargetList.svelte'
|
||||
|
||||
let redirecting = false
|
||||
|
||||
async function init () {
|
||||
await reloadServerInfo()
|
||||
}
|
||||
|
||||
async function logout () {
|
||||
await api.logout()
|
||||
await reloadServerInfo()
|
||||
}
|
||||
|
||||
function onPageResume () {
|
||||
redirecting = false
|
||||
init()
|
||||
}
|
||||
|
||||
init()
|
||||
</script>
|
||||
|
||||
<svelte:window on:pageshow={onPageResume}/>
|
||||
|
||||
<div class="container">
|
||||
{#await init()}
|
||||
<Spinner />
|
||||
{:then _}
|
||||
{#if redirecting}
|
||||
<Spinner />
|
||||
{:else}
|
||||
<div class="d-flex align-items-center mt-5 mb-5">
|
||||
<img class="logo" src={logo} alt="Warpgate" />
|
||||
|
||||
{#if $serverInfo?.username}
|
||||
<div class="ms-auto">{$serverInfo.username}</div>
|
||||
<button class="btn btn-link" on:click={logout}>
|
||||
<Fa icon={faSignOut} fw />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if $serverInfo?.username}
|
||||
<TargetList
|
||||
on:navigation={() => redirecting = true} />
|
||||
{:else}
|
||||
<Login />
|
||||
{/if}
|
||||
|
||||
<footer class="mt-5">
|
||||
{$serverInfo?.version}
|
||||
</footer>
|
||||
{/if}
|
||||
{:catch error}
|
||||
<Alert color="danger">{error}</Alert>
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.container {
|
||||
width: 500px;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 6rem;
|
||||
margin: 0 -0.5rem;
|
||||
}
|
||||
</style>
|
129
warpgate-web/src/gateway/Login.svelte
Normal file
129
warpgate-web/src/gateway/Login.svelte
Normal file
|
@ -0,0 +1,129 @@
|
|||
<script lang="ts">
|
||||
import { replace } from 'svelte-spa-router'
|
||||
import { Alert, FormGroup } from 'sveltestrap'
|
||||
import { api, LoginFailureReason, LoginFailureResponseFromJSON } from 'gateway/lib/api'
|
||||
import { reloadServerInfo } from 'gateway/lib/store'
|
||||
import AsyncButton from 'common/AsyncButton.svelte'
|
||||
|
||||
let error: Error|null = null
|
||||
let username = ''
|
||||
let password = ''
|
||||
let otp = ''
|
||||
let incorrectCredentials = false
|
||||
let otpInputVisible = false
|
||||
let busy = false
|
||||
|
||||
async function login () {
|
||||
busy = true
|
||||
try {
|
||||
await _login()
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function _login () {
|
||||
error = null
|
||||
incorrectCredentials = false
|
||||
try {
|
||||
await api.login({
|
||||
loginRequest: {
|
||||
username,
|
||||
password,
|
||||
otp: otp || undefined,
|
||||
},
|
||||
})
|
||||
let next = new URLSearchParams(location.search).get('next')
|
||||
if (next) {
|
||||
location.href = next
|
||||
} else {
|
||||
await reloadServerInfo()
|
||||
replace('/')
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.status) {
|
||||
const response = err as Response
|
||||
if (response.status === 401) {
|
||||
const failure = LoginFailureResponseFromJSON(await response.json())
|
||||
if (failure.reason === LoginFailureReason.InvalidCredentials) {
|
||||
incorrectCredentials = true
|
||||
} else if (failure.reason === LoginFailureReason.OtpNeeded) {
|
||||
presentOTPInput()
|
||||
}
|
||||
} else {
|
||||
error = new Error(await response.text())
|
||||
}
|
||||
} else {
|
||||
error = err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function presentOTPInput () {
|
||||
otpInputVisible = true
|
||||
}
|
||||
|
||||
function onInputKey (event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
login()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form class="mt-5" autocomplete="on">
|
||||
<div class="page-summary-bar">
|
||||
<h1>Welcome</h1>
|
||||
</div>
|
||||
|
||||
{#if !otpInputVisible}
|
||||
<FormGroup floating label="Username">
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input
|
||||
bind:value={username}
|
||||
on:keypress={onInputKey}
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
disabled={busy}
|
||||
class="form-control"
|
||||
autofocus />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup floating label="Password">
|
||||
<input
|
||||
bind:value={password}
|
||||
on:keypress={onInputKey}
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
disabled={busy}
|
||||
class="form-control" />
|
||||
</FormGroup>
|
||||
{/if}
|
||||
|
||||
{#if otpInputVisible}
|
||||
<FormGroup floating label="One-time password">
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input
|
||||
bind:value={otp}
|
||||
on:keypress={onInputKey}
|
||||
name="otp"
|
||||
autofocus
|
||||
disabled={busy}
|
||||
class="form-control" />
|
||||
</FormGroup>
|
||||
{/if}
|
||||
|
||||
<AsyncButton
|
||||
outline
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
click={login}
|
||||
>Login</AsyncButton>
|
||||
|
||||
{#if incorrectCredentials}
|
||||
<Alert color="danger">Incorrect credentials</Alert>
|
||||
{/if}
|
||||
{#if error}
|
||||
<Alert color="danger">{error}</Alert>
|
||||
{/if}
|
||||
</form>
|
96
warpgate-web/src/gateway/TargetList.svelte
Normal file
96
warpgate-web/src/gateway/TargetList.svelte
Normal file
|
@ -0,0 +1,96 @@
|
|||
<script lang="ts">
|
||||
import { faArrowRight } from '@fortawesome/free-solid-svg-icons'
|
||||
import { api, Target, TargetKind } from 'gateway/lib/api'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import Fa from 'svelte-fa'
|
||||
import { Badge, FormGroup, Modal, ModalBody, ModalHeader, Spinner } from 'sveltestrap'
|
||||
import { serverInfo } from './lib/store'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let targets: Target[]|undefined
|
||||
let selectedTarget: Target|undefined
|
||||
let sshUsername: string
|
||||
|
||||
$: sshUsername = `${$serverInfo?.username}:${selectedTarget?.name}`
|
||||
|
||||
async function init () {
|
||||
targets = await api.getTargets()
|
||||
}
|
||||
|
||||
function selectTarget (target: Target) {
|
||||
if (target.kind === TargetKind.Http) {
|
||||
loadURL(`/?warpgate_target=${target.name}`)
|
||||
} else if (target.kind === TargetKind.WebAdmin) {
|
||||
loadURL('/@warpgate/admin')
|
||||
} else {
|
||||
selectedTarget = target
|
||||
}
|
||||
}
|
||||
|
||||
function loadURL (url: string) {
|
||||
dispatch('navigation')
|
||||
location.href = url
|
||||
}
|
||||
init()
|
||||
|
||||
</script>
|
||||
|
||||
{#if targets}
|
||||
<div class="list-group list-group-flush">
|
||||
{#each targets as target}
|
||||
<a
|
||||
class="list-group-item list-group-item-action target-item"
|
||||
href={
|
||||
target.kind === TargetKind.Http
|
||||
? `/?warpgate_target=${target.name}`
|
||||
: '/@warpgate/admin'
|
||||
}
|
||||
on:click={e => {
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
return
|
||||
}
|
||||
selectTarget(target)
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
<span class="me-auto">{target.name}</span>
|
||||
{#if target.kind === TargetKind.Ssh}
|
||||
<Badge color="success">SSH</Badge>
|
||||
{:else}
|
||||
<Fa icon={faArrowRight} fw />
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<Spinner />
|
||||
{/if}
|
||||
|
||||
<Modal isOpen={!!selectedTarget} toggle={() => selectedTarget = undefined}>
|
||||
<ModalHeader toggle={() => selectedTarget = undefined}>
|
||||
<div>
|
||||
{selectedTarget?.name}
|
||||
</div>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
{#if selectedTarget?.kind === TargetKind.Ssh}
|
||||
<h3>Connection instructions</h3>
|
||||
|
||||
<FormGroup floating label="SSH username">
|
||||
<input type="text" class="form-control" readonly value={sshUsername} />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup floating label="Example command">
|
||||
<input type="text" class="form-control" readonly value={`ssh ${sshUsername}@warpgate-host -p ${$serverInfo?.ports.ssh}`} />
|
||||
</FormGroup>
|
||||
{/if}
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
|
||||
<style lang="scss">
|
||||
.target-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
14
warpgate-web/src/gateway/index.html
Normal file
14
warpgate-web/src/gateway/index.html
Normal file
|
@ -0,0 +1,14 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<base href="/@warpgate" />
|
||||
<link rel="icon" href="/assets/logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Warpgate</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/gateway/index.ts"></script>
|
||||
</body>
|
||||
</html>
|
10
warpgate-web/src/gateway/index.ts
Normal file
10
warpgate-web/src/gateway/index.ts
Normal file
|
@ -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 { }
|
8
warpgate-web/src/gateway/lib/api.ts
Normal file
8
warpgate-web/src/gateway/lib/api.ts
Normal file
|
@ -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'
|
188
warpgate-web/src/gateway/lib/openapi-schema.json
Normal file
188
warpgate-web/src/gateway/lib/openapi-schema.json
Normal file
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
8
warpgate-web/src/gateway/lib/store.ts
Normal file
8
warpgate-web/src/gateway/lib/store.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { writable } from 'svelte/store'
|
||||
import { api, Info } from './api'
|
||||
|
||||
export const serverInfo = writable<Info|null>(null)
|
||||
|
||||
export async function reloadServerInfo (): Promise<void> {
|
||||
serverInfo.set(await api.getInfo())
|
||||
}
|
8
warpgate-web/src/gateway/login.ts
Normal file
8
warpgate-web/src/gateway/login.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import Login from './Login.svelte'
|
||||
|
||||
const app = {}
|
||||
new Login({
|
||||
target: document.getElementById('app')!,
|
||||
})
|
||||
|
||||
export default app
|
39
warpgate-web/src/lib.rs
Normal file
39
warpgate-web/src/lib.rs
Normal file
|
@ -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<Vec<String>>,
|
||||
}
|
||||
|
||||
pub fn lookup_built_file(source: &str) -> Result<ManifestEntry, LookupError> {
|
||||
let file = Assets::get("manifest.json").ok_or(LookupError::ManifestNotFound)?;
|
||||
|
||||
let obj: HashMap<String, ManifestEntry> = serde_json::from_slice(&file.data)?;
|
||||
|
||||
obj.get(source)
|
||||
.map(Clone::clone)
|
||||
.ok_or(LookupError::FileNotFound)
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue