HTTP targets support (fixes #116)

This commit is contained in:
Eugene 2022-06-26 20:50:04 +02:00 committed by GitHub
parent 40b1083c39
commit 6342fcb3ed
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
110 changed files with 3233 additions and 1054 deletions

View file

@ -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}"

View file

@ -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:

View file

@ -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
View file

@ -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"

View file

@ -5,7 +5,9 @@ members = [
"warpgate-common",
"warpgate-db-migrations",
"warpgate-db-entities",
"warpgate-protocol-http",
"warpgate-protocol-ssh",
"warpgate-web",
]
default-members = ["warpgate"]

View file

@ -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`)

View file

@ -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

View file

@ -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"}

View file

@ -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>

View file

@ -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'

View file

@ -1,3 +0,0 @@
import { writable } from 'svelte/store'
export const authenticatedUsername = writable<string|null>(null)

View file

@ -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

View file

@ -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)
}
}

View file

@ -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"),
})))
}
}

View file

@ -1,5 +1,3 @@
use crate::helpers::{authorized, ApiResult};
use poem::session::Session;
use poem::web::Data;
use poem_openapi::param::Path;
use poem_openapi::{ApiResponse, OpenApi};
@ -29,28 +27,24 @@ impl Api {
&self,
db: Data<&Arc<Mutex<DatabaseConnection>>>,
id: Path<Uuid>,
session: &Session,
) -> ApiResult<DeleteSSHKnownHostResponse> {
authorized(session, || async move {
use warpgate_db_entities::KnownHost;
let db = db.lock().await;
) -> poem::Result<DeleteSSHKnownHostResponse> {
use warpgate_db_entities::KnownHost;
let db = db.lock().await;
let known_host = KnownHost::Entity::find_by_id(id.0)
.one(&*db)
.await
.map_err(poem::error::InternalServerError)?;
let known_host = KnownHost::Entity::find_by_id(id.0)
.one(&*db)
.await
.map_err(poem::error::InternalServerError)?;
match known_host {
Some(known_host) => {
known_host
.delete(&*db)
.await
.map_err(poem::error::InternalServerError)?;
Ok(DeleteSSHKnownHostResponse::Deleted)
}
None => Ok(DeleteSSHKnownHostResponse::NotFound),
match known_host {
Some(known_host) => {
known_host
.delete(&*db)
.await
.map_err(poem::error::InternalServerError)?;
Ok(DeleteSSHKnownHostResponse::Deleted)
}
})
.await
None => Ok(DeleteSSHKnownHostResponse::NotFound),
}
}
}

View file

@ -1,5 +1,3 @@
use crate::helpers::{authorized, ApiResult};
use poem::session::Session;
use poem::web::Data;
use poem_openapi::payload::Json;
use poem_openapi::{ApiResponse, OpenApi};
@ -26,18 +24,14 @@ impl Api {
async fn api_ssh_get_all_known_hosts(
&self,
db: Data<&Arc<Mutex<DatabaseConnection>>>,
session: &Session,
) -> ApiResult<GetSSHKnownHostsResponse> {
authorized(session, || async move {
use warpgate_db_entities::KnownHost;
) -> poem::Result<GetSSHKnownHostsResponse> {
use warpgate_db_entities::KnownHost;
let db = db.lock().await;
let hosts = KnownHost::Entity::find()
.all(&*db)
.await
.map_err(poem::error::InternalServerError)?;
Ok(GetSSHKnownHostsResponse::Ok(Json(hosts)))
})
.await
let db = db.lock().await;
let hosts = KnownHost::Entity::find()
.all(&*db)
.await
.map_err(poem::error::InternalServerError)?;
Ok(GetSSHKnownHostsResponse::Ok(Json(hosts)))
}
}

View file

@ -1,6 +1,4 @@
use crate::helpers::{authorized, ApiResult};
use chrono::{DateTime, Utc};
use poem::session::Session;
use poem::web::Data;
use poem_openapi::payload::Json;
use poem_openapi::{ApiResponse, Object, OpenApi};
@ -35,48 +33,44 @@ impl Api {
&self,
db: Data<&Arc<Mutex<DatabaseConnection>>>,
body: Json<GetLogsRequest>,
session: &Session,
) -> ApiResult<GetLogsResponse> {
authorized(session, || async move {
use warpgate_db_entities::LogEntry;
) -> poem::Result<GetLogsResponse> {
use warpgate_db_entities::LogEntry;
let db = db.lock().await;
let mut q = LogEntry::Entity::find()
.order_by_desc(LogEntry::Column::Timestamp)
.limit(body.limit.unwrap_or(100));
let db = db.lock().await;
let mut q = LogEntry::Entity::find()
.order_by_desc(LogEntry::Column::Timestamp)
.limit(body.limit.unwrap_or(100));
if let Some(before) = body.before {
q = q.filter(LogEntry::Column::Timestamp.lt(before));
}
if let Some(after) = body.after {
q = q
.filter(LogEntry::Column::Timestamp.gt(after))
.order_by_asc(LogEntry::Column::Timestamp);
}
if let Some(ref session_id) = body.session_id {
q = q.filter(LogEntry::Column::SessionId.eq(*session_id));
}
if let Some(ref username) = body.username {
q = q.filter(LogEntry::Column::SessionId.eq(username.clone()));
}
if let Some(ref search) = body.search {
q = q.filter(
LogEntry::Column::Text
.contains(search)
.or(LogEntry::Column::Username.contains(search)),
);
}
if let Some(before) = body.before {
q = q.filter(LogEntry::Column::Timestamp.lt(before));
}
if let Some(after) = body.after {
q = q
.filter(LogEntry::Column::Timestamp.gt(after))
.order_by_asc(LogEntry::Column::Timestamp);
}
if let Some(ref session_id) = body.session_id {
q = q.filter(LogEntry::Column::SessionId.eq(*session_id));
}
if let Some(ref username) = body.username {
q = q.filter(LogEntry::Column::SessionId.eq(username.clone()));
}
if let Some(ref search) = body.search {
q = q.filter(
LogEntry::Column::Text
.contains(search)
.or(LogEntry::Column::Username.contains(search)),
);
}
let logs = q
.all(&*db)
.await
.map_err(poem::error::InternalServerError)?;
let logs = logs
.into_iter()
.map(Into::into)
.collect::<Vec<LogEntry::Model>>();
Ok(GetLogsResponse::Ok(Json(logs)))
})
.await
let logs = q
.all(&*db)
.await
.map_err(poem::error::InternalServerError)?;
let logs = logs
.into_iter()
.map(Into::into)
.collect::<Vec<LogEntry::Model>>();
Ok(GetLogsResponse::Ok(Json(logs)))
}
}

View file

@ -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,
)
}

View file

@ -1,8 +1,6 @@
use crate::helpers::{authorized, ApiResult};
use bytes::Bytes;
use futures::{SinkExt, StreamExt};
use poem::error::{InternalServerError, NotFoundError};
use poem::session::Session;
use poem::web::websocket::{Message, WebSocket};
use poem::web::Data;
use poem::{handler, IntoResponse};
@ -41,22 +39,18 @@ impl Api {
&self,
db: Data<&Arc<Mutex<DatabaseConnection>>>,
id: Path<Uuid>,
session: &Session,
) -> ApiResult<GetRecordingResponse> {
authorized(session, || async move {
let db = db.lock().await;
) -> poem::Result<GetRecordingResponse> {
let db = db.lock().await;
let recording = Recording::Entity::find_by_id(id.0)
.one(&*db)
.await
.map_err(InternalServerError)?;
let recording = Recording::Entity::find_by_id(id.0)
.one(&*db)
.await
.map_err(InternalServerError)?;
match recording {
Some(recording) => Ok(GetRecordingResponse::Ok(Json(recording))),
None => Ok(GetRecordingResponse::NotFound),
}
})
.await
match recording {
Some(recording) => Ok(GetRecordingResponse::Ok(Json(recording))),
None => Ok(GetRecordingResponse::NotFound),
}
}
}
@ -65,62 +59,58 @@ pub async fn api_get_recording_cast(
db: Data<&Arc<Mutex<DatabaseConnection>>>,
recordings: Data<&Arc<Mutex<SessionRecordings>>>,
id: poem::web::Path<Uuid>,
session: &Session,
) -> ApiResult<String> {
authorized(session, || async move {
let db = db.lock().await;
) -> poem::Result<String> {
let db = db.lock().await;
let recording = Recording::Entity::find_by_id(id.0)
.one(&*db)
.await
.map_err(InternalServerError)?;
let recording = Recording::Entity::find_by_id(id.0)
.one(&*db)
.await
.map_err(InternalServerError)?;
let Some(recording) = recording else {
let Some(recording) = recording else {
return Err(NotFoundError.into())
};
if recording.kind != RecordingKind::Terminal {
return Err(NotFoundError.into());
if recording.kind != RecordingKind::Terminal {
return Err(NotFoundError.into());
}
let path = {
recordings
.lock()
.await
.path_for(&recording.session_id, &recording.name)
};
let mut response = vec![]; //String::new();
let mut last_size = (0, 0);
let file = File::open(&path).await.map_err(InternalServerError)?;
let reader = BufReader::new(file);
let mut lines = reader.lines();
while let Some(line) = lines.next_line().await.map_err(InternalServerError)? {
let entry: TerminalRecordingItem =
serde_json::from_str(&line[..]).map_err(InternalServerError)?;
let asciicast: AsciiCast = entry.into();
response.push(serde_json::to_string(&asciicast).map_err(InternalServerError)?);
if let AsciiCast::Header { width, height, .. } = asciicast {
last_size = (width, height);
}
}
let path = {
recordings
.lock()
.await
.path_for(&recording.session_id, &recording.name)
};
response.insert(
0,
serde_json::to_string(&AsciiCast::Header {
time: 0.0,
version: 2,
width: last_size.0,
height: last_size.1,
title: recording.name,
})
.map_err(InternalServerError)?,
);
let mut response = vec![]; //String::new();
let mut last_size = (0, 0);
let file = File::open(&path).await.map_err(InternalServerError)?;
let reader = BufReader::new(file);
let mut lines = reader.lines();
while let Some(line) = lines.next_line().await.map_err(InternalServerError)? {
let entry: TerminalRecordingItem =
serde_json::from_str(&line[..]).map_err(InternalServerError)?;
let asciicast: AsciiCast = entry.into();
response.push(serde_json::to_string(&asciicast).map_err(InternalServerError)?);
if let AsciiCast::Header { width, height, .. } = asciicast {
last_size = (width, height);
}
}
response.insert(
0,
serde_json::to_string(&AsciiCast::Header {
time: 0.0,
version: 2,
width: last_size.0,
height: last_size.1,
title: recording.name,
})
.map_err(InternalServerError)?,
);
Ok(response.join("\n"))
})
.await
Ok(response.join("\n"))
}
#[handler]
@ -128,36 +118,32 @@ pub async fn api_get_recording_tcpdump(
db: Data<&Arc<Mutex<DatabaseConnection>>>,
recordings: Data<&Arc<Mutex<SessionRecordings>>>,
id: poem::web::Path<Uuid>,
session: &Session,
) -> ApiResult<Bytes> {
authorized(session, || async move {
let db = db.lock().await;
) -> poem::Result<Bytes> {
let db = db.lock().await;
let recording = Recording::Entity::find_by_id(id.0)
.one(&*db)
.await
.map_err(poem::error::InternalServerError)?;
let recording = Recording::Entity::find_by_id(id.0)
.one(&*db)
.await
.map_err(poem::error::InternalServerError)?;
let Some(recording) = recording else {
let Some(recording) = recording else {
return Err(NotFoundError.into())
};
if recording.kind != RecordingKind::Traffic {
return Err(NotFoundError.into());
}
if recording.kind != RecordingKind::Traffic {
return Err(NotFoundError.into());
}
let path = {
recordings
.lock()
.await
.path_for(&recording.session_id, &recording.name)
};
let path = {
recordings
.lock()
.await
.path_for(&recording.session_id, &recording.name)
};
let content = std::fs::read(path).map_err(InternalServerError)?;
let content = std::fs::read(path).map_err(InternalServerError)?;
Ok(Bytes::from(content))
})
.await
Ok(Bytes::from(content))
}
#[handler]

View file

@ -1,4 +1,3 @@
use crate::helpers::{authorized, ApiResult};
use poem::web::Data;
use poem_openapi::param::Path;
use poem_openapi::payload::Json;
@ -42,22 +41,18 @@ impl Api {
&self,
db: Data<&Arc<Mutex<DatabaseConnection>>>,
id: Path<Uuid>,
session: &poem::session::Session,
) -> ApiResult<GetSessionResponse> {
authorized(session, || async move {
let db = db.lock().await;
) -> poem::Result<GetSessionResponse> {
let db = db.lock().await;
let session = Session::Entity::find_by_id(id.0)
.one(&*db)
.await
.map_err(poem::error::InternalServerError)?;
let session = Session::Entity::find_by_id(id.0)
.one(&*db)
.await
.map_err(poem::error::InternalServerError)?;
match session {
Some(session) => Ok(GetSessionResponse::Ok(Json(session.into()))),
None => Ok(GetSessionResponse::NotFound),
}
})
.await
match session {
Some(session) => Ok(GetSessionResponse::Ok(Json(session.into()))),
None => Ok(GetSessionResponse::NotFound),
}
}
#[oai(
@ -69,19 +64,15 @@ impl Api {
&self,
db: Data<&Arc<Mutex<DatabaseConnection>>>,
id: Path<Uuid>,
session: &poem::session::Session,
) -> ApiResult<GetSessionRecordingsResponse> {
authorized(session, || async move {
let db = db.lock().await;
let recordings: Vec<Recording::Model> = Recording::Entity::find()
.order_by_desc(Recording::Column::Started)
.filter(Recording::Column::SessionId.eq(id.0))
.all(&*db)
.await
.map_err(poem::error::InternalServerError)?;
Ok(GetSessionRecordingsResponse::Ok(Json(recordings)))
})
.await
) -> poem::Result<GetSessionRecordingsResponse> {
let db = db.lock().await;
let recordings: Vec<Recording::Model> = Recording::Entity::find()
.order_by_desc(Recording::Column::Started)
.filter(Recording::Column::SessionId.eq(id.0))
.all(&*db)
.await
.map_err(poem::error::InternalServerError)?;
Ok(GetSessionRecordingsResponse::Ok(Json(recordings)))
}
#[oai(
@ -93,19 +84,15 @@ impl Api {
&self,
state: Data<&Arc<Mutex<State>>>,
id: Path<Uuid>,
session: &poem::session::Session,
) -> ApiResult<CloseSessionResponse> {
authorized(session, || async move {
let state = state.lock().await;
) -> poem::Result<CloseSessionResponse> {
let state = state.lock().await;
if let Some(s) = state.sessions.get(&id) {
let mut session = s.lock().await;
session.handle.close();
Ok(CloseSessionResponse::Ok)
} else {
Ok(CloseSessionResponse::NotFound)
}
})
.await
if let Some(s) = state.sessions.get(&id) {
let mut session = s.lock().await;
session.handle.close();
Ok(CloseSessionResponse::Ok)
} else {
Ok(CloseSessionResponse::NotFound)
}
}
}

View file

@ -1,5 +1,3 @@
use crate::helpers::{authorized, ApiResult};
use poem::session::Session;
use poem::web::Data;
use poem_openapi::payload::Json;
use poem_openapi::{ApiResponse, OpenApi};
@ -28,24 +26,20 @@ impl Api {
async fn api_get_all_sessions(
&self,
db: Data<&Arc<Mutex<DatabaseConnection>>>,
session: &Session,
) -> ApiResult<GetSessionsResponse> {
authorized(session, || async move {
use warpgate_db_entities::Session;
) -> poem::Result<GetSessionsResponse> {
use warpgate_db_entities::Session;
let db = db.lock().await;
let sessions = Session::Entity::find()
.order_by_desc(Session::Column::Started)
.all(&*db)
.await
.map_err(poem::error::InternalServerError)?;
let sessions = sessions
.into_iter()
.map(Into::into)
.collect::<Vec<SessionSnapshot>>();
Ok(GetSessionsResponse::Ok(Json(sessions)))
})
.await
let db = db.lock().await;
let sessions = Session::Entity::find()
.order_by_desc(Session::Column::Started)
.all(&*db)
.await
.map_err(poem::error::InternalServerError)?;
let sessions = sessions
.into_iter()
.map(Into::into)
.collect::<Vec<SessionSnapshot>>();
Ok(GetSessionsResponse::Ok(Json(sessions)))
}
#[oai(
@ -56,18 +50,14 @@ impl Api {
async fn api_close_all_sessions(
&self,
state: Data<&Arc<Mutex<State>>>,
session: &Session,
) -> ApiResult<CloseAllSessionsResponse> {
authorized(session, || async move {
let state = state.lock().await;
) -> poem::Result<CloseAllSessionsResponse> {
let state = state.lock().await;
for s in state.sessions.values() {
let mut session = s.lock().await;
session.handle.close();
}
for s in state.sessions.values() {
let mut session = s.lock().await;
session.handle.close();
}
Ok(CloseAllSessionsResponse::Ok)
})
.await
Ok(CloseAllSessionsResponse::Ok)
}
}

View file

@ -1,5 +1,3 @@
use crate::helpers::{authorized, ApiResult};
use poem::session::Session;
use poem::web::Data;
use poem_openapi::payload::Json;
use poem_openapi::{ApiResponse, Object, OpenApi};
@ -33,22 +31,18 @@ impl Api {
async fn api_ssh_get_own_keys(
&self,
config: Data<&Arc<Mutex<WarpgateConfig>>>,
session: &Session,
) -> ApiResult<GetSSHOwnKeysResponse> {
authorized(session, || async move {
let config = config.lock().await;
let keys = warpgate_protocol_ssh::load_client_keys(&config)
.map_err(poem::error::InternalServerError)?;
) -> poem::Result<GetSSHOwnKeysResponse> {
let config = config.lock().await;
let keys = warpgate_protocol_ssh::load_client_keys(&config)
.map_err(poem::error::InternalServerError)?;
let keys = keys
.into_iter()
.map(|k| SSHKey {
kind: k.name().to_owned(),
public_key_base64: k.public_key_base64().replace('\n', "").replace('\r', ""),
})
.collect();
Ok(GetSSHOwnKeysResponse::Ok(Json(keys)))
})
.await
let keys = keys
.into_iter()
.map(|k| SSHKey {
kind: k.name().to_owned(),
public_key_base64: k.public_key_base64().replace('\n', "").replace('\r', ""),
})
.collect();
Ok(GetSSHOwnKeysResponse::Ok(Json(keys)))
}
}

View file

@ -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 {
let mut targets = config_provider.lock().await.list_targets().await?;
targets.sort_by(|a, b| a.name.cmp(&b.name));
Ok(GetTargetsResponse::Ok(Json(targets)))
})
.await
) -> poem::Result<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)))
}
}

View file

@ -1,5 +1,3 @@
use crate::helpers::{authorized, ApiResult};
use poem::session::Session;
use poem::web::Data;
use poem_openapi::param::Path;
use poem_openapi::{ApiResponse, OpenApi};
@ -30,28 +28,24 @@ impl Api {
&self,
db: Data<&Arc<Mutex<DatabaseConnection>>>,
id: Path<Uuid>,
session: &Session,
) -> ApiResult<DeleteTicketResponse> {
authorized(session, || async move {
use warpgate_db_entities::Ticket;
let db = db.lock().await;
) -> poem::Result<DeleteTicketResponse> {
use warpgate_db_entities::Ticket;
let db = db.lock().await;
let ticket = Ticket::Entity::find_by_id(id.0)
.one(&*db)
.await
.map_err(poem::error::InternalServerError)?;
let ticket = Ticket::Entity::find_by_id(id.0)
.one(&*db)
.await
.map_err(poem::error::InternalServerError)?;
match ticket {
Some(ticket) => {
ticket
.delete(&*db)
.await
.map_err(poem::error::InternalServerError)?;
Ok(DeleteTicketResponse::Deleted)
}
None => Ok(DeleteTicketResponse::NotFound),
match ticket {
Some(ticket) => {
ticket
.delete(&*db)
.await
.map_err(poem::error::InternalServerError)?;
Ok(DeleteTicketResponse::Deleted)
}
})
.await
None => Ok(DeleteTicketResponse::NotFound),
}
}
}

View file

@ -1,6 +1,4 @@
use crate::helpers::{authorized, ApiResult};
use anyhow::Context;
use poem::session::Session;
use poem::web::Data;
use poem_openapi::payload::Json;
use poem_openapi::{ApiResponse, Object, OpenApi};
@ -47,23 +45,19 @@ impl Api {
async fn api_get_all_tickets(
&self,
db: Data<&Arc<Mutex<DatabaseConnection>>>,
session: &Session,
) -> ApiResult<GetTicketsResponse> {
authorized(session, || async move {
use warpgate_db_entities::Ticket;
) -> poem::Result<GetTicketsResponse> {
use warpgate_db_entities::Ticket;
let db = db.lock().await;
let tickets = Ticket::Entity::find()
.all(&*db)
.await
.map_err(poem::error::InternalServerError)?;
let tickets = tickets
.into_iter()
.map(Into::into)
.collect::<Vec<Ticket::Model>>();
Ok(GetTicketsResponse::Ok(Json(tickets)))
})
.await
let db = db.lock().await;
let tickets = Ticket::Entity::find()
.all(&*db)
.await
.map_err(poem::error::InternalServerError)?;
let tickets = tickets
.into_iter()
.map(Into::into)
.collect::<Vec<Ticket::Model>>();
Ok(GetTicketsResponse::Ok(Json(tickets)))
}
#[oai(path = "/tickets", method = "post", operation_id = "create_ticket")]
@ -71,36 +65,32 @@ impl Api {
&self,
db: Data<&Arc<Mutex<DatabaseConnection>>>,
body: Json<CreateTicketRequest>,
session: &Session,
) -> ApiResult<CreateTicketResponse> {
authorized(session, || async move {
use warpgate_db_entities::Ticket;
) -> poem::Result<CreateTicketResponse> {
use warpgate_db_entities::Ticket;
if body.username.is_empty() {
return Ok(CreateTicketResponse::BadRequest(Json("username".into())));
}
if body.target_name.is_empty() {
return Ok(CreateTicketResponse::BadRequest(Json("target_name".into())));
}
if body.username.is_empty() {
return Ok(CreateTicketResponse::BadRequest(Json("username".into())));
}
if body.target_name.is_empty() {
return Ok(CreateTicketResponse::BadRequest(Json("target_name".into())));
}
let db = db.lock().await;
let secret = generate_ticket_secret();
let values = Ticket::ActiveModel {
id: Set(Uuid::new_v4()),
secret: Set(secret.expose_secret().to_string()),
username: Set(body.username.clone()),
target: Set(body.target_name.clone()),
created: Set(chrono::Utc::now()),
..Default::default()
};
let db = db.lock().await;
let secret = generate_ticket_secret();
let values = Ticket::ActiveModel {
id: Set(Uuid::new_v4()),
secret: Set(secret.expose_secret().to_string()),
username: Set(body.username.clone()),
target: Set(body.target_name.clone()),
created: Set(chrono::Utc::now()),
..Default::default()
};
let ticket = values.insert(&*db).await.context("Error saving ticket")?;
let ticket = values.insert(&*db).await.context("Error saving ticket")?;
Ok(CreateTicketResponse::Created(Json(TicketAndSecret {
secret: secret.expose_secret().to_string(),
ticket,
})))
})
.await
Ok(CreateTicketResponse::Created(Json(TicketAndSecret {
secret: secret.expose_secret().to_string(),
ticket,
})))
}
}

View file

@ -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 {
let mut users = config_provider.lock().await.list_users().await?;
users.sort_by(|a, b| a.username.cmp(&b.username));
Ok(GetUsersResponse::Ok(Json(users)))
})
.await
) -> poem::Result<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)))
}
}

View file

@ -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
}

View file

@ -1,117 +1,44 @@
#![feature(decl_macro, proc_macro_hygiene, let_else)]
mod api;
mod helpers;
use anyhow::{Context, Result};
use poem::endpoint::{EmbeddedFileEndpoint, EmbeddedFilesEndpoint};
use poem::listener::{Listener, RustlsCertificate, RustlsConfig, TcpListener};
use poem::middleware::{AddData, SetHeader};
use poem::session::{CookieConfig, MemoryStorage, ServerSession};
use poem::{EndpointExt, Route, Server};
use poem::{EndpointExt, IntoEndpoint, Route};
use poem_openapi::OpenApiService;
use rust_embed::RustEmbed;
use std::net::SocketAddr;
use tracing::*;
use warpgate_common::Services;
#[derive(RustEmbed)]
#[folder = "../warpgate-admin/app/dist"]
pub struct Assets;
pub fn admin_api_app(services: &Services) -> impl IntoEndpoint {
let api_service = OpenApiService::new(
crate::api::get(),
"Warpgate Web Admin",
env!("CARGO_PKG_VERSION"),
)
.server("/@warpgate/admin/api");
pub struct AdminServer {
services: Services,
}
let ui = api_service.swagger_ui();
let spec = api_service.spec_endpoint();
let db = services.db.clone();
let config = services.config.clone();
let config_provider = services.config_provider.clone();
let recordings = services.recordings.clone();
let state = services.state.clone();
impl AdminServer {
pub fn new(services: &Services) -> Self {
AdminServer {
services: services.clone(),
}
}
pub async fn run(self, address: SocketAddr) -> Result<()> {
let state = self.services.state.clone();
let api_service = OpenApiService::new(
(
crate::api::sessions_list::Api,
crate::api::sessions_detail::Api,
crate::api::recordings_detail::Api,
crate::api::users_list::Api,
crate::api::targets_list::Api,
crate::api::tickets_list::Api,
crate::api::tickets_detail::Api,
crate::api::known_hosts_list::Api,
crate::api::known_hosts_detail::Api,
crate::api::info::Api,
crate::api::auth::Api,
crate::api::ssh_keys::Api,
crate::api::logs::Api,
),
"Warpgate",
env!("CARGO_PKG_VERSION"),
Route::new()
.nest("", api_service)
.nest("/swagger", ui)
.nest("/openapi.json", spec)
.at(
"/recordings/:id/cast",
crate::api::recordings_detail::api_get_recording_cast,
)
.server("/api");
let ui = api_service.swagger_ui();
let spec = api_service.spec_endpoint();
let db = self.services.db.clone();
let config = self.services.config.clone();
let config_provider = self.services.config_provider.clone();
let recordings = self.services.recordings.clone();
let app = Route::new()
.nest("/api/swagger", ui)
.nest("/api", api_service)
.nest("/api/openapi.json", spec)
.nest_no_strip("/assets", EmbeddedFilesEndpoint::<Assets>::new())
.at("/", EmbeddedFileEndpoint::<Assets>::new("index.html"))
.at(
"/api/recordings/:id/cast",
crate::api::recordings_detail::api_get_recording_cast,
)
.at(
"/api/recordings/:id/stream",
crate::api::recordings_detail::api_get_recording_stream,
)
.at(
"/api/recordings/:id/tcpdump",
crate::api::recordings_detail::api_get_recording_tcpdump,
)
.with(ServerSession::new(
CookieConfig::default().secure(false),
MemoryStorage::default(),
))
.with(SetHeader::new().overriding("Strict-Transport-Security", "max-age=31536000"))
.with(AddData::new(db))
.with(AddData::new(config_provider))
.with(AddData::new(state))
.with(AddData::new(recordings))
.with(AddData::new(config.clone()));
let (certificate, key) = {
let config = config.lock().await;
let certificate_path = config
.paths_relative_to
.join(&config.store.web_admin.certificate);
let key_path = config.paths_relative_to.join(&config.store.web_admin.key);
(
std::fs::read(&certificate_path).with_context(|| {
format!(
"reading SSL certificate from '{}'",
certificate_path.display()
)
})?,
std::fs::read(&key_path).with_context(|| {
format!("reading SSL private key from '{}'", key_path.display())
})?,
)
};
info!(?address, "Listening");
Server::new(TcpListener::bind(address).rustls(
RustlsConfig::new().fallback(RustlsCertificate::new().cert(certificate).key(key)),
))
.run(app)
.await
.context("Failed to start admin server")
}
.at(
"/recordings/:id/stream",
crate::api::recordings_detail::api_get_recording_stream,
)
.at(
"/recordings/:id/tcpdump",
crate::api::recordings_detail::api_get_recording_tcpdump,
)
.data(db)
.data(config_provider)
.data(state)
.data(recordings)
.data(config)
}

View 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());
}

View file

@ -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(),
}
}

View file

@ -107,52 +107,47 @@ impl ConfigProvider for FileConfigProvider {
let client_key = format!("{} {}", kind, base64_bytes);
debug!(username = &user.username[..], "Client key: {}", client_key);
for credential in user.credentials.iter() {
if let UserAuthCredential::PublicKey { key: ref user_key } = credential {
if &client_key == user_key.expose_secret() {
valid_credentials.push(credential);
break;
if let Some(credential) =
user.credentials.iter().find(|credential| match credential {
UserAuthCredential::PublicKey { key: ref user_key } => {
&client_key == user_key.expose_secret()
}
}
_ => false,
})
{
valid_credentials.push(credential)
}
}
AuthCredential::Password(client_password) => {
for credential in user.credentials.iter() {
if let UserAuthCredential::Password {
match user.credentials.iter().find(|credential| match credential {
UserAuthCredential::Password {
hash: ref user_password_hash,
} = credential
{
match verify_password_hash(
client_password.expose_secret(),
user_password_hash.expose_secret(),
) {
Ok(true) => {
valid_credentials.push(credential);
break;
}
Ok(false) => continue,
Err(e) => {
error!(
username = &user.username[..],
"Error verifying password hash: {}", e
);
continue;
}
}
}
} => verify_password_hash(
client_password.expose_secret(),
user_password_hash.expose_secret(),
)
.unwrap_or_else(|e| {
error!(
username = &user.username[..],
"Error verifying password hash: {}", e
);
false
}),
_ => false,
}) {
Some(credential) => valid_credentials.push(credential),
None => return Ok(AuthResult::Rejected),
}
}
AuthCredential::OTP(client_otp) => {
for credential in user.credentials.iter() {
if let UserAuthCredential::TOTP {
AuthCredential::Otp(client_otp) => {
match user.credentials.iter().find(|credential| match credential {
UserAuthCredential::TOTP {
key: ref user_otp_key,
} = credential
{
if verify_totp(client_otp.expose_secret(), user_otp_key) {
valid_credentials.push(credential);
break;
}
}
} => verify_totp(client_otp.expose_secret(), user_otp_key),
_ => false,
}) {
Some(credential) => valid_credentials.push(credential),
None => return Ok(AuthResult::Rejected),
}
}
}
@ -182,7 +177,7 @@ impl ConfigProvider for FileConfigProvider {
username: user.username.clone(),
});
} else if remaining_required_kinds.contains(&"otp".to_string()) {
return Ok(AuthResult::OTPNeeded);
return Ok(AuthResult::OtpNeeded);
} else {
return Ok(AuthResult::Rejected);
}

View file

@ -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,

View file

@ -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,
}
}
}

View file

@ -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::*;

View file

@ -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());

View file

@ -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 {

View file

@ -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,

View 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!();
});
}

View file

@ -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>,

View file

@ -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)]

View file

@ -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),
]
}
}

View 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
}
}

View 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"]}

View 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)
}
}

View 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 }
},
})))
}
}

View 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)
}

View 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(),
)))
}
}

View 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(),
})
}

View 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()
}

View 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")
}
}

View 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");
}
}

View 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());
}

View 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)
}

View 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);
}
}
}

View 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)
}
}

View file

@ -13,8 +13,8 @@ use channel_session::SessionChannel;
use futures::pin_mut;
use handler::ClientHandler;
use russh::client::Handle;
use russh::{Sig, Preferred};
use russh_keys::key::{PublicKey, self};
use russh::{Preferred, Sig};
use russh_keys::key::{self, PublicKey};
use std::collections::HashMap;
use std::net::ToSocketAddrs;
use std::sync::Arc;
@ -328,7 +328,12 @@ impl RemoteClient {
info!(?address, username = &ssh_options.username[..], "Connecting");
let config = russh::client::Config {
preferred: Preferred {
key: &[key::ED25519, key::RSA_SHA2_256, key::RSA_SHA2_512, key::SSH_RSA],
key: &[
key::ED25519,
key::RSA_SHA2_256,
key::RSA_SHA2_512,
key::SSH_RSA,
],
..<_>::default()
},
..Default::default()
@ -365,18 +370,18 @@ impl RemoteClient {
return Err(ConnectionError::Aborted)
}
session = &mut fut_connect => {
if let Err(error) = session {
let connection_error = match error {
ClientHandlerError::ConnectionError(e) => e,
ClientHandlerError::Ssh(e) => ConnectionError::SSH(e),
ClientHandlerError::Internal => ConnectionError::Internal,
};
error!(error=?connection_error, "Connection error");
return Err(connection_error);
}
#[allow(clippy::unwrap_used)]
let mut session = session.unwrap();
let mut session = match session {
Ok(session) => session,
Err(error) => {
let connection_error = match error {
ClientHandlerError::ConnectionError(e) => e,
ClientHandlerError::Ssh(e) => ConnectionError::SSH(e),
ClientHandlerError::Internal => ConnectionError::Internal,
};
error!(error=?connection_error, "Connection error");
return Err(connection_error);
}
};
let mut auth_result = false;
match ssh_options.auth {

View file

@ -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()));
};

View file

@ -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)

View file

@ -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();

View file

@ -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);
}

View file

@ -154,3 +154,4 @@ overrides:
ignorePatterns:
- svelte.config.js
- vite.config.ts
- src/*/lib/api-client/**

11
warpgate-web/Cargo.toml Normal file
View 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"

View file

@ -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",

View file

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -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,37 +51,41 @@ const routes = {
}
</script>
<div class="app container">
<header>
<a use:link use:active href="/" class="d-flex">
<img class="logo" src={logo} alt="Logo" />
</a>
{#if $authenticatedUsername}
<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}
<div class="username">
<!-- {$authenticatedUsername} -->
</div>
<button class="btn btn-link" on:click={logout}>
<Fa icon={faSignOut} fw />
</button>
{/if}
</header>
<main>
<Router {routes}/>
</main>
<footer>
{version}
</footer>
</div>
{#await init()}
<Spinner />
{:then}
<div class="app container">
<header>
<a href="/@warpgate" class="d-flex">
<img class="logo" src={logo} alt="Logo" />
</a>
{#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 $serverInfo?.username}
<div class="username">
{$serverInfo?.username}
</div>
<button class="btn btn-link" on:click={logout}>
<Fa icon={faSignOut} fw />
</button>
{/if}
</header>
<main>
<Router {routes}/>
</main>
<footer>
{$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>

View file

@ -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}

View file

@ -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}>
Close all sessions
</Button>
<div class="ms-auto">
<AsyncButton outline click={closeAllSesssions}>
Close all sessions
</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;

View file

@ -1,5 +1,5 @@
<script lang="ts">
import LogViewer from 'LogViewer.svelte'
import LogViewer from './LogViewer.svelte'
</script>
<div class="page-summary-bar">

View file

@ -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%;

View file

@ -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 => {

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { timeAgo } from 'lib/time'
import { timeAgo } from 'admin/lib/time'
export let date: any
</script>

View file

@ -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

View file

@ -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}>
Close now
</Button>
<div class="ms-auto">
<AsyncButton outline click={close}>
Close now
</AsyncButton>
</div>
{/if}
</div>

View file

@ -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">

View file

@ -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

View file

@ -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>

View 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 { }

View 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'

View file

@ -1,12 +1,12 @@
{
"openapi": "3.0.0",
"info": {
"title": "Warpgate",
"version": "0.1.1"
"title": "Warpgate Web Admin",
"version": "0.2.5"
},
"servers": [
{
"url": "/api"
"url": "/@warpgate/admin/api"
}
],
"tags": [],
@ -325,56 +325,6 @@
"operationId": "delete_ssh_known_host"
}
},
"/info": {
"get": {
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Info"
}
}
}
}
},
"operationId": "get_info"
}
},
"/auth/login": {
"post": {
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LoginRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"description": ""
},
"401": {
"description": ""
}
},
"operationId": "login"
}
},
"/auth/logout": {
"post": {
"responses": {
"201": {
"description": ""
}
},
"operationId": "logout"
}
},
"/ssh/own-keys": {
"get": {
"responses": {
@ -470,20 +420,6 @@
}
}
},
"Info": {
"type": "object",
"required": [
"version"
],
"properties": {
"version": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
"LogEntry": {
"type": "object",
"required": [
@ -515,21 +451,6 @@
}
}
},
"LoginRequest": {
"type": "object",
"required": [
"username",
"password"
],
"properties": {
"username": {
"type": "string"
},
"password": {
"type": "string"
}
}
},
"Recording": {
"type": "object",
"required": [
@ -619,7 +540,8 @@
"type": "object",
"required": [
"id",
"started"
"started",
"protocol"
],
"properties": {
"id": {
@ -643,6 +565,9 @@
"ticket_id": {
"type": "string",
"format": "uuid"
},
"protocol": {
"type": "string"
}
}
},
@ -650,7 +575,8 @@
"type": "object",
"required": [
"name",
"allow_roles"
"allow_roles",
"options"
],
"properties": {
"name": {
@ -662,14 +588,96 @@
"type": "string"
}
},
"ssh": {
"$ref": "#/components/schemas/TargetSSHOptions"
},
"web_admin": {
"$ref": "#/components/schemas/TargetWebAdminOptions"
"options": {
"$ref": "#/components/schemas/TargetOptions"
}
}
},
"TargetHTTPOptions": {
"type": "object",
"required": [
"url"
],
"properties": {
"url": {
"type": "string"
},
"headers": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
},
"TargetOptions": {
"type": "object",
"anyOf": [
{
"required": [
"kind"
],
"allOf": [
{
"$ref": "#/components/schemas/TargetSSHOptions"
},
{
"type": "object",
"title": "TargetSSHOptions",
"properties": {
"kind": {
"type": "string",
"example": "TargetSSHOptions"
}
}
}
]
},
{
"required": [
"kind"
],
"allOf": [
{
"$ref": "#/components/schemas/TargetHTTPOptions"
},
{
"type": "object",
"title": "TargetHTTPOptions",
"properties": {
"kind": {
"type": "string",
"example": "TargetHTTPOptions"
}
}
}
]
},
{
"required": [
"kind"
],
"allOf": [
{
"$ref": "#/components/schemas/TargetWebAdminOptions"
},
{
"type": "object",
"title": "TargetWebAdminOptions",
"properties": {
"kind": {
"type": "string",
"example": "TargetWebAdminOptions"
}
}
}
]
}
],
"discriminator": {
"propertyName": "kind"
}
},
"TargetSSHOptions": {
"type": "object",
"required": [

View file

@ -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;

View 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>

View 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>

View 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,
}))

View 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>

View 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>

View 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>

View 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>

View 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 { }

View 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'

View 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"
]
}
}
}
}

View 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())
}

View 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
View 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)
}

View file

@ -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);
}

View file

@ -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