This commit is contained in:
Eugene Pankov 2022-04-10 22:58:58 +02:00
commit 4ccf2b0437
No known key found for this signature in database
GPG key ID: 5896FCBBDD1CF4F4
130 changed files with 16485 additions and 0 deletions

2
.cargo/config Normal file
View file

@ -0,0 +1,2 @@
[build]
rustflags = ["--cfg", "tokio_unstable"]

1
.env Normal file
View file

@ -0,0 +1 @@
DATABASE_URL=sqlite:data/db/db.sqlite3?mode=rwc

67
.github/workflows/build.yml vendored Normal file
View file

@ -0,0 +1,67 @@
name: Build
on: [push, pull_request]
jobs:
Build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Cache
uses: actions/cache@v2
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Install just
run: |
cargo install just
- name: Install admin UI deps
run: |
just yarn
- name: Build admin UI
run: |
just yarn openapi-client
just yarn build
- name: Build
uses: actions-rs/cargo@v1
with:
command: build
use-cross: true
args: --release --target x86_64-unknown-linux-gnu
- name: Rename
run: |
mkdir dist
mv target/x86_64-unknown-linux-gnu/release/warpgate dist/warpgate-${{github.ref_name}}-x86_64-linux
- uses: actions/upload-artifact@master
name: Upload artifacts
with:
name: warpgate-${{github.ref_name}}-x86_64-linux
path: dist/warpgate-${{github.ref_name}}-x86_64-linux
# - name: 🔎 Test
# uses: actions-rs/cargo@v1
# with:
# command: test
- name: Upload
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/v')
with:
draft: true
append_body: true
generate_release_notes: true
files: dist/*
token: ${{ secrets.GITHUB_TOKEN }}
env:
GITHUB_REPOSITORY: my_gh_org/my_gh_repo

20
.github/workflows/dependency-review.yml vendored Normal file
View file

@ -0,0 +1,20 @@
# Dependency Review Action
#
# This Action will scan dependency manifest files that change as part of a Pull Reqest, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging.
#
# Source repository: https://github.com/actions/dependency-review-action
# Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement
name: 'Dependency Review'
on: [pull_request]
permissions:
contents: read
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: 'Checkout Repository'
uses: actions/checkout@v3
- name: 'Dependency Review'
uses: actions/dependency-review-action@v1

18
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,18 @@
name: "Release"
on:
push:
tags:
- "v*"
jobs:
tagged-release:
name: "Tagged Release"
runs-on: "ubuntu-latest"
steps:
- uses: "marvinpinto/action-automatic-releases@latest"
with:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
prerelease: false
draft: true

20
.gitignore vendored Normal file
View file

@ -0,0 +1,20 @@
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
temp
host_key*
.vscode
# ---
data
config.*.yaml
config.yaml

4312
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

15
Cargo.toml Normal file
View file

@ -0,0 +1,15 @@
[workspace]
members = [
"warpgate",
"warpgate-admin",
"warpgate-common",
"warpgate-db-migrations",
"warpgate-db-entities",
"warpgate-protocol-ssh",
]
default-members = ["warpgate"]
[profile.release]
lto = true
panic = "abort"
strip = "debuginfo"

201
LICENSE Normal file
View file

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

33
README.md Normal file
View file

@ -0,0 +1,33 @@
# Warpgate
Warpgate is a smart SSH bastion host for Linux that can be used with _any_ SSH client.
* Set it up in your DMZ, add user accounts and easily assign them to specific hosts within the network.
* Warpgate will record every session for you to replay and review later through a built-in admin web UI.
* Single-file statically linked binary with no dependencies.
* Written in 100% safe Rust.
## Getting started
See the [Getting started](https://github.com/Eugeny/warpgate/wiki/Getting-started) wiki page.
## Project Status
The project is currently in **alpha** stage and is gathering community feedback. See the [official roadmap](https://github.com/users/Eugeny/projects/1/views/2) for the upcoming features.
In particular, we're working on:
* Support for exposing HTTP(S) endpoints through the bastion,
* Support for tunneling database connections,
* Live session view and control,
* Requesting admin approval for sessions
* and much more.
## Contributing / building from source
* 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 frontend: `just yarn build`
* Build Warpgate: `cargo build` (optionally `--release`)

198
deny.toml Normal file
View file

@ -0,0 +1,198 @@
# This template contains all of the possible sections and their default values
# Note that all fields that take a lint level have these possible values:
# * deny - An error will be produced and the check will fail
# * warn - A warning will be produced, but the check will not fail
# * allow - No warning or error will be produced, though in some cases a note
# will be
# The values provided in this template are the default values that will be used
# when any section or field is not specified in your own configuration
# If 1 or more target triples (and optionally, target_features) are specified,
# only the specified targets will be checked when running `cargo deny check`.
# This means, if a particular package is only ever used as a target specific
# dependency, such as, for example, the `nix` crate only being used via the
# `target_family = "unix"` configuration, that only having windows targets in
# this list would mean the nix crate, as well as any of its exclusive
# dependencies not shared by any other crates, would be ignored, as the target
# list here is effectively saying which targets you are building for.
targets = [
# The triple can be any string, but only the target triples built in to
# rustc (as of 1.40) can be checked against actual config expressions
#{ triple = "x86_64-unknown-linux-musl" },
# You can also specify which target_features you promise are enabled for a
# particular target. target_features are currently not validated against
# the actual valid features supported by the target architecture.
#{ triple = "wasm32-unknown-unknown", features = ["atomics"] },
]
# This section is considered when running `cargo deny check advisories`
# More documentation for the advisories section can be found here:
# https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html
[advisories]
# The path where the advisory database is cloned/fetched into
db-path = "~/.cargo/advisory-db"
# The url(s) of the advisory databases to use
db-urls = ["https://github.com/rustsec/advisory-db"]
# The lint level for security vulnerabilities
vulnerability = "deny"
# The lint level for unmaintained crates
unmaintained = "warn"
# The lint level for crates that have been yanked from their source registry
yanked = "warn"
# The lint level for crates with security notices. Note that as of
# 2019-12-17 there are no security notice advisories in
# https://github.com/rustsec/advisory-db
notice = "warn"
# A list of advisory IDs to ignore. Note that ignored advisories will still
# output a note when they are encountered.
ignore = [
#"RUSTSEC-0000-0000",
]
# Threshold for security vulnerabilities, any vulnerability with a CVSS score
# lower than the range specified will be ignored. Note that ignored advisories
# will still output a note when they are encountered.
# * None - CVSS Score 0.0
# * Low - CVSS Score 0.1 - 3.9
# * Medium - CVSS Score 4.0 - 6.9
# * High - CVSS Score 7.0 - 8.9
# * Critical - CVSS Score 9.0 - 10.0
#severity-threshold =
# This section is considered when running `cargo deny check licenses`
# More documentation for the licenses section can be found here:
# https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html
[licenses]
# The lint level for crates which do not have a detectable license
unlicensed = "deny"
# List of explicitly allowed licenses
# See https://spdx.org/licenses/ for list of possible licenses
# [possible values: any SPDX 3.11 short identifier (+ optional exception)].
allow = [
"MIT",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"WTFPL",
]
# List of explicitly disallowed licenses
# See https://spdx.org/licenses/ for list of possible licenses
# [possible values: any SPDX 3.11 short identifier (+ optional exception)].
deny = [
#"Nokia",
]
# Lint level for licenses considered copyleft
copyleft = "warn"
# Blanket approval or denial for OSI-approved or FSF Free/Libre licenses
# * both - The license will be approved if it is both OSI-approved *AND* FSF
# * either - The license will be approved if it is either OSI-approved *OR* FSF
# * osi-only - The license will be approved if is OSI-approved *AND NOT* FSF
# * fsf-only - The license will be approved if is FSF *AND NOT* OSI-approved
# * neither - This predicate is ignored and the default lint level is used
allow-osi-fsf-free = "either"
# Lint level used when no other predicates are matched
# 1. License isn't in the allow or deny lists
# 2. License isn't copyleft
# 3. License isn't OSI/FSF, or allow-osi-fsf-free = "neither"
default = "deny"
# The confidence threshold for detecting a license from license text.
# The higher the value, the more closely the license text must be to the
# canonical license text of a valid SPDX license file.
# [possible values: any between 0.0 and 1.0].
confidence-threshold = 0.8
# Allow 1 or more licenses on a per-crate basis, so that particular licenses
# aren't accepted for every possible crate as with the normal allow list
exceptions = [
# Each entry is the crate and version constraint, and its specific allow
# list
#{ allow = ["Zlib"], name = "adler32", version = "*" },
]
# Some crates don't have (easily) machine readable licensing information,
# adding a clarification entry for it allows you to manually specify the
# licensing information
#[[licenses.clarify]]
# The name of the crate the clarification applies to
#name = "ring"
# The optional version constraint for the crate
#version = "*"
# The SPDX expression for the license requirements of the crate
#expression = "MIT AND ISC AND OpenSSL"
# One or more files in the crate's source used as the "source of truth" for
# the license expression. If the contents match, the clarification will be used
# when running the license check, otherwise the clarification will be ignored
# and the crate will be checked normally, which may produce warnings or errors
# depending on the rest of your configuration
#license-files = [
# Each entry is a crate relative path, and the (opaque) hash of its contents
#{ path = "LICENSE", hash = 0xbd0eed23 }
#]
[licenses.private]
# If true, ignores workspace crates that aren't published, or are only
# published to private registries.
# To see how to mark a crate as unpublished (to the official registry),
# visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field.
ignore = false
# One or more private registries that you might publish crates to, if a crate
# is only published to private registries, and ignore is true, the crate will
# not have its license(s) checked
registries = [
#"https://sekretz.com/registry
]
# This section is considered when running `cargo deny check bans`.
# More documentation about the 'bans' section can be found here:
# https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html
[bans]
# Lint level for when multiple versions of the same crate are detected
multiple-versions = "warn"
# Lint level for when a crate version requirement is `*`
wildcards = "allow"
# The graph highlighting used when creating dotgraphs for crates
# with multiple versions
# * lowest-version - The path to the lowest versioned duplicate is highlighted
# * simplest-path - The path to the version with the fewest edges is highlighted
# * all - Both lowest-version and simplest-path are used
highlight = "all"
# List of crates that are allowed. Use with care!
allow = [
#{ name = "ansi_term", version = "=0.11.0" },
]
# List of crates to deny
deny = [
# Each entry the name of a crate and a version range. If version is
# not specified, all versions will be matched.
#{ name = "ansi_term", version = "=0.11.0" },
#
# Wrapper crates can optionally be specified to allow the crate when it
# is a direct dependency of the otherwise banned crate
#{ name = "ansi_term", version = "=0.11.0", wrappers = [] },
]
# Certain crates/versions that will be skipped when doing duplicate detection.
skip = [
#{ name = "ansi_term", version = "=0.11.0" },
]
# Similarly to `skip` allows you to skip certain crates during duplicate
# detection. Unlike skip, it also includes the entire tree of transitive
# dependencies starting at the specified crate, up to a certain depth, which is
# by default infinite
skip-tree = [
#{ name = "ansi_term", version = "=0.11.0", depth = 20 },
]
# This section is considered when running `cargo deny check sources`.
# More documentation about the 'sources' section can be found here:
# https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html
[sources]
# Lint level for what to happen when a crate from a crate registry that is not
# in the allow list is encountered
unknown-registry = "warn"
# Lint level for what to happen when a crate from a git repository that is not
# in the allow list is encountered
unknown-git = "warn"
# List of URLs for allowed crate registries. Defaults to the crates.io index
# if not specified. If it is specified but empty, no registries are allowed.
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
# List of URLs for allowed Git repositories
allow-git = []

27
justfile Normal file
View file

@ -0,0 +1,27 @@
projects := "warpgate warpgate-admin warpgate-common warpgate-db-entities warpgate-db-migrations warpgate-protocol-ssh"
run *ARGS:
RUST_BACKTRACE=1 RUST_LOG=warpgate cd warpgate && cargo run -- --config ../config.yaml {{ARGS}}
fmt:
for p in {{projects}}; do cargo fmt -p $p -v; done
fix *ARGS:
for p in {{projects}}; do cargo fix -p $p {{ARGS}}; done
clippy *ARGS:
for p in {{projects}}; do cargo clippy -p $p {{ARGS}}; done
yarn *ARGS:
cd warpgate-admin/app/ && yarn {{ARGS}}
svelte-check:
cd warpgate-admin/app/ && yarn run check
openapi-all:
cd warpgate-admin/app/ && yarn openapi-schema && yarn openapi-client
openapi:
cd warpgate-admin/app/ && yarn openapi-client
cleanup: (fix "--allow-dirty") (clippy "--fix" "--allow-dirty") fmt

1
russh Symbolic link
View file

@ -0,0 +1 @@
rust-russh/russh

2
rust-toolchain.toml Normal file
View file

@ -0,0 +1,2 @@
[toolchain]
channel = "nightly-2022-03-14"

1
rustfmt.toml Normal file
View file

@ -0,0 +1 @@
imports_granularity = "Module"

28
warpgate-admin/Cargo.toml Normal file
View file

@ -0,0 +1,28 @@
[package]
edition = "2021"
license = "Apache-2.0"
name = "warpgate-admin"
version = "0.1.0"
[dependencies]
anyhow = {version = "1.0", features = ["std"]}
async-trait = "0.1"
bytes = "1.1"
chrono = "0.4"
futures = "0.3"
hex = "0.4"
mime_guess = "2.0"
poem = {version = "1.3", features = ["cookie", "session", "anyhow", "rustls"]}
poem-openapi = {version = "1.3", features = ["swagger-ui", "chrono", "uuid", "static-files"]}
russh-keys = {version = "0.22.0-beta.1", features = ["openssl"]}
rust-embed = "6.3"
sea-orm = {version = "^0.6", features = ["sqlx-sqlite", "runtime-tokio-native-tls", "macros"], default-features = false}
serde = "1.0"
serde_json = "1.0"
thiserror = "1.0"
tokio = {version = "1.17", features = ["tracing"]}
tracing = "0.1"
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"}

View file

@ -0,0 +1,9 @@
root = true
[*]
indent_style = space
indent_size = 4
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
end_of_line = lf

View file

@ -0,0 +1,140 @@
parser: '@typescript-eslint/parser'
parserOptions:
sourceType: module
project:
- ./tsconfig.json
extraFileExtensions:
- .svelte
env:
es6: true
browser: true
extends:
- 'plugin:import/recommended'
- 'plugin:import/typescript'
- 'plugin:@typescript-eslint/all'
plugins:
- import
- svelte3
- '@typescript-eslint/eslint-plugin'
settings:
svelte3/typescript: true
import/resolver:
typescript: {}
rules:
'@typescript-eslint/semi':
- error
- never
'@typescript-eslint/indent':
- error
- 4
'@typescript-eslint/explicit-member-accessibility':
- error
- accessibility: no-public
overrides:
parameterProperties: explicit
'@typescript-eslint/no-require-imports': 'off'
'@typescript-eslint/no-parameter-properties': 'off'
'@typescript-eslint/explicit-function-return-type': 'off'
'@typescript-eslint/no-explicit-any': 'off'
'@typescript-eslint/no-magic-numbers': 'off'
'@typescript-eslint/member-delimiter-style': 'off'
'@typescript-eslint/promise-function-async': 'off'
'@typescript-eslint/require-array-sort-compare': 'off'
'@typescript-eslint/no-floating-promises': 'off'
'@typescript-eslint/prefer-readonly': 'off'
'@typescript-eslint/require-await': 'off'
'@typescript-eslint/strict-boolean-expressions': 'off'
'@typescript-eslint/no-misused-promises':
- error
- checksVoidReturn: false
'@typescript-eslint/typedef': 'off'
'@typescript-eslint/consistent-type-imports': 'off'
'@typescript-eslint/sort-type-union-intersection-members': 'off'
'@typescript-eslint/no-use-before-define':
- error
- classes: false
no-duplicate-imports: error
array-bracket-spacing:
- error
- never
block-scoped-var: error
brace-style: 'off'
'@typescript-eslint/brace-style':
- error
- 1tbs
- allowSingleLine: true
computed-property-spacing:
- error
- never
curly: error
eol-last: error
eqeqeq:
- error
- smart
max-depth:
- 1
- 5
max-statements:
- 1
- 80
no-multiple-empty-lines: error
no-mixed-spaces-and-tabs: error
no-trailing-spaces: error
'@typescript-eslint/no-unused-vars':
- error
- vars: all
args: after-used
argsIgnorePattern: ^_
no-undef: error
no-var: error
object-curly-spacing: 'off'
'@typescript-eslint/object-curly-spacing':
- error
- always
quote-props:
- warn
- as-needed
- keywords: true
numbers: true
quotes: 'off'
'@typescript-eslint/quotes':
- error
- single
- allowTemplateLiterals: true
'@typescript-eslint/no-confusing-void-expression':
- error
- ignoreArrowShorthand: true
'@typescript-eslint/no-non-null-assertion': 'off'
'@typescript-eslint/no-unnecessary-condition':
- error
- allowConstantLoopConditions: true
'@typescript-eslint/restrict-template-expressions': 'off'
'@typescript-eslint/prefer-readonly-parameter-types': 'off'
'@typescript-eslint/no-unsafe-member-access': 'off'
'@typescript-eslint/no-unsafe-call': 'off'
'@typescript-eslint/no-unsafe-return': 'off'
'@typescript-eslint/no-unsafe-assignment': 'off'
'@typescript-eslint/naming-convention': 'off'
'@typescript-eslint/lines-between-class-members':
- error
- exceptAfterSingleLine: true
'@typescript-eslint/dot-notation': 'off'
'@typescript-eslint/no-implicit-any-catch': 'off'
'@typescript-eslint/member-ordering': 'off'
'@typescript-eslint/no-var-requires': 'off'
'@typescript-eslint/no-unsafe-argument': 'off'
'@typescript-eslint/restrict-plus-operands': 'off'
'@typescript-eslint/space-infix-ops': 'off'
'@typescript-eslint/no-type-alias':
- error
- allowAliases: in-unions-and-intersections
allowLiterals: always
allowCallbacks: always
overrides:
- files: '*.svelte'
processor: svelte3/svelte3
ignorePatterns:
- svelte.config.js
- vite.config.ts

28
warpgate-admin/app/.gitignore vendored Normal file
View file

@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
#---
api-client

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<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/main.ts"></script>
</body>
</html>

View file

@ -0,0 +1,669 @@
{
"openapi": "3.0.0",
"info": {
"title": "Warpgate",
"version": "0.1.0"
},
"servers": [
{
"url": "/api"
}
],
"tags": [],
"paths": {
"/sessions": {
"get": {
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SessionSnapshot"
}
}
}
}
}
},
"operationId": "get_sessions"
},
"delete": {
"responses": {
"201": {
"description": ""
}
},
"operationId": "close_all_sessions"
}
},
"/sessions/{id}": {
"get": {
"parameters": [
{
"name": "id",
"schema": {
"type": "string",
"format": "uuid"
},
"in": "path",
"required": true,
"deprecated": false
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionSnapshot"
}
}
}
},
"404": {
"description": ""
}
},
"operationId": "get_session"
}
},
"/sessions/{id}/recordings": {
"get": {
"parameters": [
{
"name": "id",
"schema": {
"type": "string",
"format": "uuid"
},
"in": "path",
"required": true,
"deprecated": false
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Recording"
}
}
}
}
}
},
"operationId": "get_session_recordings"
}
},
"/sessions/{id}/close": {
"post": {
"parameters": [
{
"name": "id",
"schema": {
"type": "string",
"format": "uuid"
},
"in": "path",
"required": true,
"deprecated": false
}
],
"responses": {
"201": {
"description": ""
},
"404": {
"description": ""
}
},
"operationId": "close_session"
}
},
"/recordings/{id}": {
"get": {
"parameters": [
{
"name": "id",
"schema": {
"type": "string",
"format": "uuid"
},
"in": "path",
"required": true,
"deprecated": false
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Recording"
}
}
}
},
"404": {
"description": ""
}
},
"operationId": "get_recording"
}
},
"/users": {
"get": {
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/UserSnapshot"
}
}
}
}
}
},
"operationId": "get_users"
}
},
"/targets": {
"get": {
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Target"
}
}
}
}
}
},
"operationId": "get_targets"
}
},
"/tickets": {
"get": {
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Ticket"
}
}
}
}
}
},
"operationId": "get_tickets"
},
"post": {
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateTicketRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TicketAndSecret"
}
}
}
},
"400": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "string"
}
}
}
}
},
"operationId": "create_ticket"
}
},
"/tickets/{id}": {
"delete": {
"parameters": [
{
"name": "id",
"schema": {
"type": "string",
"format": "uuid"
},
"in": "path",
"required": true,
"deprecated": false
}
],
"responses": {
"204": {
"description": ""
},
"404": {
"description": ""
}
},
"operationId": "delete_ticket"
}
},
"/ssh/known-hosts": {
"get": {
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SSHKnownHost"
}
}
}
}
}
},
"operationId": "get_ssh_known_hosts"
}
},
"/ssh/known-hosts/{id}": {
"delete": {
"parameters": [
{
"name": "id",
"schema": {
"type": "string",
"format": "uuid"
},
"in": "path",
"required": true,
"deprecated": false
}
],
"responses": {
"204": {
"description": ""
},
"404": {
"description": ""
}
},
"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": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SSHKey"
}
}
}
}
}
},
"operationId": "get_ssh_own_keys"
}
}
},
"components": {
"schemas": {
"CreateTicketRequest": {
"type": "object",
"required": [
"username",
"target_name"
],
"properties": {
"username": {
"type": "string"
},
"target_name": {
"type": "string"
}
}
},
"Info": {
"type": "object",
"required": [
"version"
],
"properties": {
"version": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
"LoginRequest": {
"type": "object",
"required": [
"username",
"password"
],
"properties": {
"username": {
"type": "string"
},
"password": {
"type": "string"
}
}
},
"Recording": {
"type": "object",
"required": [
"id",
"name",
"started",
"session_id",
"kind"
],
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"started": {
"type": "string",
"format": "date-time"
},
"ended": {
"type": "string",
"format": "date-time"
},
"session_id": {
"type": "string",
"format": "uuid"
},
"kind": {
"$ref": "#/components/schemas/RecordingKind"
}
}
},
"RecordingKind": {
"type": "string",
"enum": [
"Terminal",
"Traffic"
]
},
"SSHKey": {
"type": "object",
"required": [
"kind",
"public_key_base64"
],
"properties": {
"kind": {
"type": "string"
},
"public_key_base64": {
"type": "string"
}
}
},
"SSHKnownHost": {
"type": "object",
"required": [
"id",
"host",
"port",
"key_type",
"key_base64"
],
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"host": {
"type": "string"
},
"port": {
"type": "integer",
"format": "uint16"
},
"key_type": {
"type": "string"
},
"key_base64": {
"type": "string"
}
}
},
"SessionSnapshot": {
"type": "object",
"required": [
"id",
"started"
],
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"username": {
"type": "string"
},
"target": {
"$ref": "#/components/schemas/Target"
},
"started": {
"type": "string",
"format": "date-time"
},
"ended": {
"type": "string",
"format": "date-time"
},
"ticket_id": {
"type": "string",
"format": "uuid"
}
}
},
"Target": {
"type": "object",
"required": [
"name",
"allow_roles"
],
"properties": {
"name": {
"type": "string"
},
"allow_roles": {
"type": "array",
"items": {
"type": "string"
}
},
"ssh": {
"$ref": "#/components/schemas/TargetSSHOptions"
},
"web_admin": {
"$ref": "#/components/schemas/TargetWebAdminOptions"
}
}
},
"TargetSSHOptions": {
"type": "object",
"required": [
"host",
"port",
"username"
],
"properties": {
"host": {
"type": "string"
},
"port": {
"type": "integer",
"format": "uint16"
},
"username": {
"type": "string"
}
}
},
"TargetWebAdminOptions": {
"type": "object"
},
"Ticket": {
"type": "object",
"required": [
"id",
"username",
"target",
"created"
],
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"username": {
"type": "string"
},
"target": {
"type": "string"
},
"uses_left": {
"type": "integer",
"format": "uint32"
},
"expiry": {
"type": "string",
"format": "date-time"
},
"created": {
"type": "string",
"format": "date-time"
}
}
},
"TicketAndSecret": {
"type": "object",
"required": [
"ticket",
"secret"
],
"properties": {
"ticket": {
"$ref": "#/components/schemas/Ticket"
},
"secret": {
"type": "string"
}
}
},
"UserSnapshot": {
"type": "object",
"required": [
"username"
],
"properties": {
"username": {
"type": "string"
}
}
}
}
}
}

View file

@ -0,0 +1,7 @@
{
"$schema": "node_modules/@openapitools/openapi-generator-cli/config.schema.json",
"spaces": 2,
"generator-cli": {
"version": "5.4.0"
}
}

View file

@ -0,0 +1,51 @@
{
"name": "warpgate-admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"watch": "vite build -w --mode development",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"lint": "eslint src",
"postinstall": "yarn run openapi-client",
"openapi-schema": "curl http://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 && npm run build",
"openapi": "yarn run openapi-schema && yarn run openapi-client"
},
"devDependencies": {
"@fontsource/work-sans": "^4.5.7",
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.0.0",
"@openapitools/openapi-generator-cli": "^2.4.26",
"@sveltejs/vite-plugin-svelte": "^1.0.0-next.30",
"@tsconfig/svelte": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^5.12.0",
"@typescript-eslint/parser": "^5.12.0",
"asciinema-player": "3.0.0-rc.1",
"bootstrap": "^5.1.3",
"eslint": "^8.9.0",
"eslint-config-standard": "^16.0.3",
"eslint-import-resolver-typescript": "^2.5.0",
"eslint-plugin-import": "^2.23.4",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^6.0.0",
"eslint-plugin-svelte3": "^3.4.0",
"moment": "^2.29.2",
"sass": "^1.49.8",
"svelte": "^3.44.0",
"svelte-check": "^2.2.7",
"svelte-fa": "^2.4.0",
"svelte-preprocess": "^4.9.8",
"svelte-spa-router": "^3.2.0",
"sveltestrap": "^5.8.5",
"thenby": "^1.3.4",
"tslib": "^2.3.1",
"typescript": "^4.5.4",
"vite": "^2.8.0",
"vite-plugin-checker": "^0.4.2",
"vite-tsconfig-paths": "^3.4.0"
}
}

View file

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 80 47" style="enable-background:new 0 0 80 47;" xml:space="preserve">
<style type="text/css">
.st0{display:none;fill:url(#SVGID_1_);}
.st1{fill:url(#SVGID_2_);}
.st2{clip-path:url(#SVGID_4_);}
.st3{fill:url(#SVGID_5_);}
</style>
<g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="10.5" y1="23.25" x2="25.3182" y2="23.25">
<stop offset="0" style="stop-color:#005BAA"/>
<stop offset="1" style="stop-color:#C82AFF"/>
</linearGradient>
<line class="st0" x1="10.5" y1="11.5" x2="25.32" y2="35"/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="17.9092" y1="6.746" x2="17.9092" y2="42.5092">
<stop offset="0" style="stop-color:#005BAA"/>
<stop offset="1" style="stop-color:#C82AFF"/>
</linearGradient>
<path class="st1" d="M25.33,42c-2.32,0-4.6-1.16-5.93-3.27L4.58,15.23C2.52,11.96,3.5,7.64,6.77,5.58
c3.27-2.06,7.59-1.08,9.65,2.19l14.82,23.5c2.06,3.27,1.08,7.59-2.19,9.65C27.89,41.65,26.6,42,25.33,42z"/>
</g>
<g>
<defs>
<polygon id="SVGID_3_" points="33,10 13,42 56,52 86,27 87,0 33,-4 "/>
</defs>
<clipPath id="SVGID_4_">
<use xlink:href="#SVGID_3_" style="overflow:visible;"/>
</clipPath>
<g class="st2">
<linearGradient id="SVGID_5_" gradientUnits="userSpaceOnUse" x1="40.3635" y1="41.0594" x2="40.3635" y2="5.2934">
<stop offset="0" style="stop-color:#000019"/>
<stop offset="1" style="stop-color:#2A44FF"/>
</linearGradient>
<path class="st3" d="M55.18,42L55.18,42c-2.4,0-4.64-1.23-5.92-3.27l-8.9-14.11l-8.9,14.11C30.18,40.77,27.95,42,25.55,42
s-4.64-1.23-5.92-3.27L4.81,15.23c-2.06-3.27-1.08-7.59,2.19-9.65c3.27-2.06,7.59-1.08,9.65,2.19l8.9,14.11l8.9-14.11
c1.28-2.03,3.52-3.27,5.92-3.27l0,0c2.4,0,4.64,1.23,5.92,3.27l8.9,14.11l8.9-14.11c2.06-3.27,6.38-4.25,9.65-2.19
c3.27,2.06,4.25,6.38,2.19,9.65L61.1,38.73C59.82,40.77,57.58,42,55.18,42z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,136 @@
<script lang="ts">
import { faSignOut } from '@fortawesome/free-solid-svg-icons'
import { api } from 'lib/api'
import { authenticatedUsername } from 'lib/store'
import Fa from 'svelte-fa'
import logo from '../public/assets/logo.svg'
import Router, { link, push } from 'svelte-spa-router'
import active from 'svelte-spa-router/active'
import { wrap } from 'svelte-spa-router/wrap'
let version = ''
async function init () {
const info = await api.getInfo()
version = info.version
authenticatedUsername.set(info.username ?? null)
if (!info.username) {
push('/login')
}
}
async function logout () {
await api.logout()
authenticatedUsername.set(null)
push('/login')
}
init()
const routes = {
'/': wrap({
asyncComponent: () => import('./Home.svelte')
}),
'/login': wrap({
asyncComponent: () => import('./Login.svelte')
}),
'/sessions/:id': wrap({
asyncComponent: () => import('./Session.svelte')
}),
'/recordings/:id': wrap({
asyncComponent: () => import('./Recording.svelte')
}),
'/tickets': wrap({
asyncComponent: () => import('./Tickets.svelte')
}),
'/tickets/create': wrap({
asyncComponent: () => import('./CreateTicket.svelte')
}),
'/targets': wrap({
asyncComponent: () => import('./Targets.svelte')
}),
'/ssh': wrap({
asyncComponent: () => import('./SSH.svelte')
}),
}
</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>
{/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>
<style lang="scss">
@import "./vars";
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.logo {
width: 40px;
padding-top: 2px;
}
header, footer {
flex: none;
}
main {
flex: 1 0 0;
}
header {
display: flex;
align-items: center;
padding: 10px 0;
margin: 10px 0 20px;
border-bottom: 1px solid rgba($body-color, .75);
a, .logo {
font-size: 1.5rem;
}
a:not(:first-child) {
margin-left: 15px;
}
.username {
margin-left: auto;
}
}
footer {
display: flex;
padding: 10px 0;
margin: 20px 0 10px;
border-top: 1px solid rgba($body-color, .75);
}
</style>

View file

@ -0,0 +1,108 @@
<script lang="ts">
import { api, UserSnapshot, Target, TicketAndSecret } from 'lib/api'
import { link } from 'svelte-spa-router'
import { Alert, Button, FormGroup } from 'sveltestrap'
import { firstBy } from 'thenby'
let error: Error|null = null
let targets: Target[]|undefined
let users: UserSnapshot[]|undefined
let selectedTarget: Target|undefined
let selectedUser: UserSnapshot|undefined
let result: TicketAndSecret|undefined
async function load () {
[targets, users] = await Promise.all([
api.getTargets(),
api.getUsers(),
])
targets.sort(firstBy('name'))
users.sort(firstBy('username'))
}
load().catch(e => {
error = e
})
async function create () {
if (!selectedTarget || !selectedUser) {
return
}
try {
result = await api.createTicket({
createTicketRequest: {
username: selectedUser.username,
targetName: selectedTarget.name,
}
})
} catch (err) {
error = err
}
}
</script>
{#if error}
<Alert color="danger">{error}</Alert>
{/if}
{#if result}
<div class="page-summary-bar">
<h1>Ticket created</h1>
</div>
<Alert color="warning" fade={false}>
The secret is only shown once - you won't be able to see it again.
</Alert>
{#if selectedTarget?.ssh}
<h3>Connection instructions</h3>
<FormGroup floating label="SSH username">
<input type="text" class="form-control" readonly value={"ticket-" + result.secret} />
</FormGroup>
<FormGroup floating label="Example command">
<input type="text" class="form-control" readonly value={"ssh ticket-" + result.secret + "@warpgate-host -p warpgate-port"} />
</FormGroup>
{/if}
<a
class="btn btn-secondary"
href="/tickets"
use:link
>Done</a>
{:else}
<div class="page-summary-bar">
<h1>Create an access ticket</h1>
</div>
{#if users}
<FormGroup floating label="Authorize as user">
<select bind:value={selectedUser} class="form-control">
{#each users as user}
<option value={user}>
{user.username}
</option>
{/each}
</select>
</FormGroup>
{/if}
{#if targets}
<FormGroup floating label="Target">
<select bind:value={selectedTarget} class="form-control">
{#each targets as target}
<option value={target}>
{target.name}
</option>
{/each}
</select>
</FormGroup>
{/if}
<Button
outline
on:click={create}
>Create ticket</Button>
{/if}

View file

@ -0,0 +1,110 @@
<script lang="ts">
import Fa from 'svelte-fa'
import { faCircleDot as iconActive } from '@fortawesome/free-regular-svg-icons'
import { Spinner, Button } from 'sveltestrap'
import { onDestroy } from 'svelte'
import { link } from 'svelte-spa-router'
import { api, SessionSnapshot } from 'lib/api'
import { derived, writable } from 'svelte/store'
import { firstBy } from 'thenby'
import moment from 'moment'
import RelativeDate from 'RelativeDate.svelte'
const sessions = writable<SessionSnapshot[]|null>(null)
async function reloadSessions (): Promise<void> {
sessions.set(await api.getSessions())
}
async function closeAllSesssions () {
await api.closeAllSessions()
}
function describeSession (session: SessionSnapshot): string {
let user = session.username ?? (session.ended ? '<not logged in>' : '<logging in>')
if (!session.target) {
return user
}
let target = session.target.name
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')
.thenBy(x => x.ended ?? x.started, 'desc')
))
reloadSessions()
const interval = setInterval(reloadSessions, 1000)
onDestroy(() => clearInterval(interval))
</script>
{#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>
{:else}
<h1>No active sessions</h1>
{/if}
</div>
{#if $sortedSessions }
<div class="list-group list-group-flush">
{#each $sortedSessions as session}
<a
class="list-group-item list-group-item-action"
href="/sessions/{session.id}"
use:link>
<div class="main">
<div class="icon" class:text-success={!session.ended}>
{#if !session.ended}
<Fa icon={iconActive} fw />
{/if}
</div>
<strong>
{describeSession(session)}
</strong>
<div class="meta">
{#if session.ended }
{moment.duration(moment(session.ended).diff(session.started)).humanize()}
{/if}
</div>
<div class="meta ms-auto">
<RelativeDate date={session.started} />
</div>
</div>
</a>
{/each}
</div>
{/if}
{/if}
<style lang="scss">
.list-group-item {
.icon {
display: flex;
align-items: center;
margin-right: 5px;
width: 20px;
}
.main {
display: flex;
align-items: center;
}
.meta {
opacity: .75;
margin-left: 25px;
font-size: .75rem;
}
}
</style>

View file

@ -0,0 +1,80 @@
<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
function onInputKey (event: KeyboardEvent) {
if (event.key === 'Enter') {
login()
}
}
async function login () {
error = null
incorrectCredentials = false
try {
await api.login({
loginRequest: {
username,
password,
},
})
} catch (error) {
if (error.status === 401) {
incorrectCredentials = true
} else {
error = error
}
return
}
const info = await api.getInfo()
authenticatedUsername.set(info.username!)
replace('/')
}
</script>
<div class="mt-5 row">
<div class="col-12 col-md-3"></div>
<div 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}
class="form-control"
autofocus />
</FormGroup>
<FormGroup floating label="Password">
<input
bind:value={password}
on:keypress={onInputKey}
type="password"
class="form-control" />
</FormGroup>
<Button
outline
on:click={login}
>Login</Button>
{#if incorrectCredentials}
<Alert color="danger">Incorrect credentials</Alert>
{/if}
{#if error}
<Alert color="danger">{error}</Alert>
{/if}
</div>
<div class="col-12 col-md-3"></div>
</div>

View file

@ -0,0 +1,49 @@
<script lang="ts">
import { api, Recording, RecordingKind } from 'lib/api'
import { Alert, Spinner } from 'sveltestrap'
import * as AsciinemaPlayer from 'asciinema-player'
export let params = { id: '' }
let error: Error|null = null
let recording: Recording|null = null
let playerContainer: HTMLDivElement
async function load () {
recording = await api.getRecording(params)
if (recording.kind === 'Terminal') {
AsciinemaPlayer.create(`/api/recordings/${params.id}/cast`, playerContainer)
}
}
function getTCPDumpURL () {
return `/api/recordings/${recording?.id}/tcpdump`
}
load().catch(e => {
error = e
})
</script>
<div class="page-summary-bar">
<h1>Session recording</h1>
</div>
{#if !recording && !error}
<Spinner />
{/if}
{#if error}
<Alert color="danger">{error}</Alert>
{/if}
{#if recording?.kind === 'Traffic'}
<a href={getTCPDumpURL()}>Download tcpdump file</a>
{/if}
<div bind:this={playerContainer}></div>
<style lang="scss">
@import "asciinema-player/dist/bundle/asciinema-player.css";
</style>

View file

@ -0,0 +1,6 @@
<script lang="ts">
import { timeAgo } from 'lib/time'
export let date: any
</script>
<span title={date}>{timeAgo(date)}</span>

View file

@ -0,0 +1,73 @@
<script lang="ts">
import { api, SSHKey, SSHKnownHost } from 'lib/api'
import { Alert } from 'sveltestrap'
let error: Error|undefined
let knownHosts: SSHKnownHost[]|undefined
let ownKeys: SSHKey[]|undefined
async function load () {
ownKeys = await api.getSshOwnKeys()
knownHosts = await api.getSshKnownHosts()
}
load().catch(e => {
error = e
})
async function deleteHost (host: SSHKnownHost) {
await api.deleteSshKnownHost(host)
load()
}
</script>
<div class="page-summary-bar">
<h1>SSH</h1>
</div>
{#if error}
<Alert color="danger">{error}</Alert>
{/if}
{#if ownKeys}
<h2>Warpgate's own SSH keys</h2>
<Alert color="info">Add these keys to the targets' <code>authorized_hosts</code> files</Alert>
<div class="list-group list-group-flush">
{#each ownKeys as key}
<div class="list-group-item">
<pre>{key.kind} {key.publicKeyBase64}</pre>
</div>
{/each}
</div>
{/if}
<div class="mb-3"></div>
{#if knownHosts}
{#if knownHosts.length }
<h2>Known hosts: {knownHosts.length}</h2>
{:else}
<h2>No known hosts</h2>
{/if}
<div class="list-group list-group-flush">
{#each knownHosts as host}
<div class="list-group-item">
<div class="d-flex">
<strong>
{host.host}:{host.port}
</strong>
<a class="ms-auto" href={''} on:click|preventDefault={() => deleteHost(host)}>Delete</a>
</div>
<pre>{host.keyType} {host.keyBase64}</pre>
</div>
{/each}
</div>
{/if}
<style lang="scss">
pre {
word-break: break-word;
white-space: normal;
}
</style>

View file

@ -0,0 +1,123 @@
<script lang="ts">
import { api, SessionSnapshot, Recording } from 'lib/api'
import { timeAgo } from 'lib/time'
import moment from 'moment'
import RelativeDate from 'RelativeDate.svelte';
import { onDestroy } from 'svelte';
import { link } from 'svelte-spa-router'
import { Alert, Button, FormGroup, Spinner } from 'sveltestrap'
export let params = { id: '' }
let error: Error|null = null
let session: SessionSnapshot|null = null
let recordings: Recording[]|null = null
async function load () {
session = await api.getSession(params)
recordings = await api.getSessionRecordings(params)
}
async function close () {
api.closeSession(session!)
}
function getTargetDescription () {
if (session?.target) {
return `${session.target.name} (${session.target.ssh?.host}:${session.target.ssh?.port})`
} else {
return 'Not selected yet'
}
}
load().catch(e => {
error = e
})
const interval = setInterval(load, 1000)
onDestroy(() => clearInterval(interval))
</script>
{#if !session && !error}
<Spinner />
{/if}
{#if error}
<Alert color="danger">{error}</Alert>
{/if}
{#if session}
<div class="page-summary-bar">
<div>
<h1>Session</h1>
<div class="text-muted">
{#if session.ended}
{moment.duration(moment(session.ended).diff(session.started)).humanize()} long, <RelativeDate date={session.started} />
{:else}
{moment.duration(moment().diff(session.started)).humanize()}
{/if}
</div>
</div>
{#if !session.ended}
<Button class="ms-auto" outline on:click={close}>
Close now
</Button>
{/if}
</div>
<div class="row mb-4">
<div class="col-12 col-md-6">
<FormGroup floating label="User">
{#if session.username}
<input type="text" class="form-control" readonly value={session.username} />
{:else}
<input type="text" class="form-control" readonly value="Not logged in" />
{/if}
</FormGroup>
</div>
<div class="col-12 col-md-6">
<FormGroup floating label="Target">
<input type="text" class="form-control" readonly value={getTargetDescription()} />
</FormGroup>
</div>
</div>
{#if recordings?.length }
<h3>Recordings</h3>
<div class="list-group list-group-flush">
{#each recordings as recording}
<a
class="list-group-item list-group-item-action"
href="/recordings/{recording.id}"
use:link>
<div class="main">
<strong>
{recording.name}
</strong>
<small class="meta ms-auto">
{timeAgo(recording.started)}
</small>
</div>
</a>
{/each}
</div>
{/if}
{/if}
<style lang="scss">
.list-group-item {
.main {
display: flex;
align-items: center;
> * {
margin-right: 20px;
}
}
.meta {
opacity: .75;
}
}
</style>

View file

@ -0,0 +1,111 @@
<script lang="ts">
import { api, Target, UserSnapshot } from 'lib/api'
import { getSSHUsername } from 'lib/ssh';
import { Alert, FormGroup, Modal, ModalBody, ModalHeader } from 'sveltestrap'
let error: Error|undefined
let targets: Target[]|undefined
let selectedTarget: Target|undefined
let users: UserSnapshot[]|undefined
let selectedUser: UserSnapshot|undefined
let sshUsername = ''
async function load () {
targets = await api.getTargets()
users = await api.getUsers()
selectedUser = users[0]
}
load().catch(e => {
error = e
})
$: sshUsername = getSSHUsername(selectedUser, selectedTarget)
</script>
{#if error}
<Alert color="danger">{error}</Alert>
{/if}
{#if targets }
<div class="page-summary-bar">
<h1>Targets</h1>
</div>
<Alert color="info" fade={false}>Add or remove targets in the config file.</Alert>
<div class="list-group list-group-flush">
{#each targets as target}
<!-- svelte-ignore a11y-missing-attribute -->
<a class="list-group-item list-group-item-action" on:click={() => selectedTarget = target}>
<strong class="me-auto">
{target.name}
</strong>
<small class="text-muted ms-auto">
{#if target.ssh}
SSH
{/if}
{#if target.webAdmin}
This web admin interface
{/if}
</small>
</a>
{/each}
</div>
<Modal isOpen={!!selectedTarget} toggle={() => selectedTarget = undefined}>
<ModalHeader toggle={() => selectedTarget = undefined}>
<div>
{selectedTarget?.name}
</div>
<div class="target-type-label">
{#if selectedTarget?.ssh}
SSH target
{/if}
{#if selectedTarget?.webAdmin}
This web admin interface
{/if}
</div>
</ModalHeader>
<ModalBody>
{#if selectedTarget?.ssh}
<h3>Connection instructions</h3>
{#if users}
<FormGroup floating label="Select a user">
<select bind:value={selectedUser} class="form-control">
{#each users as user}
<option value={user}>
{user.username}
</option>
{/each}
</select>
</FormGroup>
{/if}
<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 warpgate-port"} />
</FormGroup>
{/if}
</ModalBody>
</Modal>
{/if}
<style lang="scss">
.list-group-item {
display: flex;
align-items: center;
}
.target-type-label {
font-size: 0.8rem;
opacity: .75;
}
:global(.modal-title) {
line-height: 1.2;
}
</style>

View file

@ -0,0 +1,71 @@
<script lang="ts">
import { api, Ticket } from 'lib/api'
import { link } from 'svelte-spa-router'
import { Alert } from 'sveltestrap'
import RelativeDate from 'RelativeDate.svelte';
let error: Error|undefined
let tickets: Ticket[]|undefined
async function load () {
tickets = await api.getTickets()
}
load().catch(e => {
error = e
})
async function deleteTicket (ticket: Ticket) {
await api.deleteTicket(ticket)
load()
}
</script>
{#if error}
<Alert color="danger">{error}</Alert>
{/if}
{#if tickets }
<div class="page-summary-bar">
{#if tickets.length }
<h1>Access tickets: {tickets.length}</h1>
{:else}
<h1>No tickets created yet</h1>
{/if}
<a
class="btn btn-outline-secondary ms-auto"
href="/tickets/create"
use:link>
Create a ticket
</a>
</div>
{#if tickets.length }
<div class="list-group list-group-flush">
{#each tickets as ticket}
<div class="list-group-item">
<strong class="me-auto">
Access to {ticket.target} as {ticket.username}
</strong>
<small class="text-muted me-4">
<RelativeDate date={ticket.created} />
</small>
<a href={''} on:click|preventDefault={() => deleteTicket(ticket)}>Delete</a>
</div>
{/each}
</div>
{:else}
<Alert color="info" fade={false}>
Tickets are secret keys that allow access to one specific target without any additional authentication.
</Alert>
{/if}
{/if}
<style lang="scss">
.list-group-item {
display: flex;
align-items: center;
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

View file

@ -0,0 +1,8 @@
import { DefaultApi, Configuration } from '../../api-client/src'
const configuration = new Configuration({
basePath: '/api'
})
export const api = new DefaultApi(configuration)
export * from '../../api-client/src/models'

View file

@ -0,0 +1,5 @@
import type { Target, UserSnapshot } from './api'
export function getSSHUsername (user: UserSnapshot|undefined, target: Target|undefined): string {
return `${user?.username ?? "<username>"}:${target?.name}`
}

View file

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

View file

@ -0,0 +1,5 @@
import moment from 'moment'
export function timeAgo(t: any): string {
return moment(t).fromNow()
}

View file

@ -0,0 +1,9 @@
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

@ -0,0 +1,92 @@
@import "bootstrap/scss/functions";
// @import "bootstrap/scss/variables";
@import "./vars";
// $component-hover-bg: rgba(#fff, .05);
// $component-active-color: hsl(224deg 100% 78%);
// $component-active-bg: hsl(224deg 73% 21% / 52%);
// $list-group-bg: transparent;
// $list-group-color: #bbb;
// $list-group-hover-bg: $component-hover-bg;
// $list-group-action-hover-color: #fff;
// Configuration
@import "bootstrap/scss/mixins";
@import "bootstrap/scss/utilities";
// Layout & components
@import "bootstrap/scss/root";
@import "bootstrap/scss/reboot";
@import "bootstrap/scss/type";
// @import "bootstrap/scss/images";
@import "bootstrap/scss/containers";
@import "bootstrap/scss/grid";
// @import "bootstrap/scss/tables";
@import "bootstrap/scss/forms";
@import "bootstrap/scss/buttons";
@import "bootstrap/scss/transitions";
// @import "bootstrap/scss/dropdown";
// @import "bootstrap/scss/button-group";
@import "bootstrap/scss/nav";
@import "bootstrap/scss/navbar";
// @import "bootstrap/scss/card";
// @import "bootstrap/scss/accordion";
// @import "bootstrap/scss/breadcrumb";
// @import "bootstrap/scss/pagination";
// @import "bootstrap/scss/badge";
@import "bootstrap/scss/alert";
// @import "bootstrap/scss/progress";
@import "bootstrap/scss/list-group";
@import "bootstrap/scss/close";
// @import "bootstrap/scss/toasts";
@import "bootstrap/scss/modal";
@import "bootstrap/scss/tooltip";
// @import "bootstrap/scss/popover";
// @import "bootstrap/scss/carousel";
@import "bootstrap/scss/spinners";
// @import "bootstrap/scss/offcanvas";
// @import "bootstrap/scss/placeholders";
// Helpers
@import "bootstrap/scss/helpers";
// Utilities
@import "bootstrap/scss/utilities/api";
// .list-group-flush > .list-group-item {
// border-radius: $border-radius;
// text-shadow: 0 0 1px black;
// }
a {
text-decoration-color: rgba($body-color, 0.25);
text-underline-offset: 2px;
&:hover, &.active {
text-decoration-color: $body-color;
}
}
.page-summary-bar {
display: flex;
align-items: center;
margin: 0.25rem 0 1.5rem;
h1 {
margin: 0;
}
}
.alert {
background: none !important;
border-top-style: none;
border-bottom-style: none;
border-right-style: none;
border-left-width: 2px;
font-style: italic;
padding: 0 10px;
margin: 20px 0;
}

View file

@ -0,0 +1,11 @@
@import "../node_modules/bootstrap/scss/functions";
$body-bg: #fffcf6;
$body-color: #555;
$font-family-sans-serif: "Work Sans", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
$link-color: $body-color;
$list-group-bg: transparent;
$alert-border-radius: 0;
$alert-border-scale: -30%;
@import "../node_modules/bootstrap/scss/variables";

5
warpgate-admin/app/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />
// eslint-disable-next-line @typescript-eslint/no-type-alias
declare type GlobalFetch = WindowOrWorkerGlobalScope

View file

@ -0,0 +1,13 @@
import sveltePreprocess from 'svelte-preprocess'
export default {
compilerOptions: {
enableSourcemap: true,
},
preprocess: sveltePreprocess({
sourceMap: true,
}),
experimental: {
prebundleSvelteLibraries: true,
},
}

View file

@ -0,0 +1,37 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "esnext",
"useDefineForClassFields": true,
"module": "esnext",
"resolveJsonModule": true,
"strictNullChecks": true,
"baseUrl": ".",
"preserveValueImports": false,
"noUnusedLocals": false,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable checkJs if you'd like to use dynamic types in JS.
* Note that setting allowJs false does not prevent the use
* of JS in `.svelte` files.
*/
"allowJs": true,
"checkJs": true,
"paths": {
"*": [
"src/*"
]
}
},
"include": [
"src/**/*.d.ts",
"src/**/*.ts",
"src/**/*.js",
"src/**/*.svelte"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

View file

@ -0,0 +1,8 @@
{
"compilerOptions": {
"composite": true,
"module": "esnext",
"moduleResolution": "node"
},
"include": ["vite.config.ts"]
}

View file

@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import tsconfigPaths from 'vite-tsconfig-paths'
import * as checker from 'vite-plugin-checker/lib/main.js'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
svelte(),
tsconfigPaths(),
(checker.default.default)({ typescript: true }),
],
build: {
sourcemap: true,
},
})

2607
warpgate-admin/app/yarn.lock Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,74 @@
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),
}
}
#[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

@ -0,0 +1,30 @@
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

@ -0,0 +1,56 @@
use crate::helpers::{authorized, ApiResult};
use poem::session::Session;
use poem::web::Data;
use poem_openapi::param::Path;
use poem_openapi::{ApiResponse, OpenApi};
use sea_orm::{DatabaseConnection, EntityTrait, ModelTrait};
use std::sync::Arc;
use tokio::sync::Mutex;
use uuid::Uuid;
pub struct Api;
#[derive(ApiResponse)]
enum DeleteSSHKnownHostResponse {
#[oai(status = 204)]
Deleted,
#[oai(status = 404)]
NotFound,
}
#[OpenApi]
impl Api {
#[oai(
path = "/ssh/known-hosts/:id",
method = "delete",
operation_id = "delete_ssh_known_host"
)]
async fn api_ssh_delete_known_host(
&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;
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),
}
})
.await
}
}

View file

@ -0,0 +1,43 @@
use crate::helpers::{authorized, ApiResult};
use poem::session::Session;
use poem::web::Data;
use poem_openapi::payload::Json;
use poem_openapi::{ApiResponse, OpenApi};
use sea_orm::{DatabaseConnection, EntityTrait};
use std::sync::Arc;
use tokio::sync::Mutex;
use warpgate_db_entities::KnownHost;
pub struct Api;
#[derive(ApiResponse)]
enum GetSSHKnownHostsResponse {
#[oai(status = 200)]
Ok(Json<Vec<KnownHost::Model>>),
}
#[OpenApi]
impl Api {
#[oai(
path = "/ssh/known-hosts",
method = "get",
operation_id = "get_ssh_known_hosts"
)]
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;
let db = db.lock().await;
let hosts = KnownHost::Entity::find()
.all(&*db)
.await
.map_err(poem::error::InternalServerError)?;
Ok(GetSSHKnownHostsResponse::Ok(Json(hosts)))
})
.await
}
}

View file

@ -0,0 +1,12 @@
pub mod auth;
pub mod info;
pub mod known_hosts_detail;
pub mod known_hosts_list;
pub mod recordings_detail;
pub mod sessions_detail;
pub mod sessions_list;
pub mod ssh_keys;
pub mod targets_list;
pub mod tickets_detail;
pub mod tickets_list;
pub mod users_list;

View file

@ -0,0 +1,191 @@
use crate::helpers::{authorized, ApiResult};
use bytes::Bytes;
use poem::error::{InternalServerError, NotFoundError};
use poem::handler;
use poem::session::Session;
use poem::web::Data;
use poem_openapi::param::Path;
use poem_openapi::payload::Json;
use poem_openapi::{ApiResponse, OpenApi};
use sea_orm::{DatabaseConnection, EntityTrait};
use serde::Serialize;
use std::sync::Arc;
use tokio::fs::File;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::Mutex;
use uuid::Uuid;
use warpgate_common::recordings::{SessionRecordings, TerminalRecordingItem};
use warpgate_db_entities::Recording::{self, RecordingKind};
pub struct Api;
#[derive(ApiResponse)]
enum GetRecordingResponse {
#[oai(status = 200)]
Ok(Json<Recording::Model>),
#[oai(status = 404)]
NotFound,
}
#[OpenApi]
impl Api {
#[oai(
path = "/recordings/:id",
method = "get",
operation_id = "get_recording"
)]
async fn api_get_recording(
&self,
db: Data<&Arc<Mutex<DatabaseConnection>>>,
id: Path<Uuid>,
session: &Session,
) -> ApiResult<GetRecordingResponse> {
authorized(session, || async move {
let db = db.lock().await;
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
}
}
#[handler]
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;
let recording = Recording::Entity::find_by_id(id.0)
.one(&*db)
.await
.map_err(InternalServerError)?;
let Some(recording) = recording else {
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)?;
match entry {
TerminalRecordingItem::Data { time, data } => {
response.push(
serde_json::to_string(&Cast::Output(
time,
"o".to_string(),
String::from_utf8_lossy(&data[..]).to_string(),
))
.map_err(InternalServerError)?,
);
}
TerminalRecordingItem::PtyResize { cols, rows, .. } => {
last_size = (cols, rows);
}
}
}
response.insert(
0,
serde_json::to_string(&Cast::Header {
version: 2,
width: last_size.0,
height: last_size.1,
title: recording.name,
})
.map_err(InternalServerError)?,
);
Ok(response.join("\n"))
})
.await
}
#[handler]
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;
let recording = Recording::Entity::find_by_id(id.0)
.one(&*db)
.await
.map_err(poem::error::InternalServerError)?;
let Some(recording) = recording else {
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 content = std::fs::read(path).map_err(InternalServerError)?;
Ok(Bytes::from(content))
})
.await
}
#[derive(Serialize)]
#[serde(untagged)]
enum Cast {
Header {
version: u32,
width: u32,
height: u32,
title: String,
},
Output(f32, String, String),
}
// #[handler]
// pub async fn api_get_recording_stream(
// ws: WebSocket,
// db: Data<&Arc<Mutex<DatabaseConnection>>>,
// state: Data<&Arc<Mutex<State>>>,
// id: poem::web::Path<Uuid>,
// ) -> impl IntoResponse {
// ws.on_upgrade(|socket| async move {
// })
// }

View file

@ -0,0 +1,111 @@
use crate::helpers::{authorized, ApiResult};
use poem::web::Data;
use poem_openapi::param::Path;
use poem_openapi::payload::Json;
use poem_openapi::{ApiResponse, OpenApi};
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder};
use std::sync::Arc;
use tokio::sync::Mutex;
use uuid::Uuid;
use warpgate_common::{SessionSnapshot, State};
use warpgate_db_entities::{Recording, Session};
pub struct Api;
#[allow(clippy::large_enum_variant)]
#[derive(ApiResponse)]
enum GetSessionResponse {
#[oai(status = 200)]
Ok(Json<SessionSnapshot>),
#[oai(status = 404)]
NotFound,
}
#[derive(ApiResponse)]
enum GetSessionRecordingsResponse {
#[oai(status = 200)]
Ok(Json<Vec<Recording::Model>>),
}
#[derive(ApiResponse)]
enum CloseSessionResponse {
#[oai(status = 201)]
Ok,
#[oai(status = 404)]
NotFound,
}
#[OpenApi]
impl Api {
#[oai(path = "/sessions/:id", method = "get", operation_id = "get_session")]
async fn api_get_session(
&self,
db: Data<&Arc<Mutex<DatabaseConnection>>>,
id: Path<Uuid>,
session: &poem::session::Session,
) -> ApiResult<GetSessionResponse> {
authorized(session, || async move {
let db = db.lock().await;
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
}
#[oai(
path = "/sessions/:id/recordings",
method = "get",
operation_id = "get_session_recordings"
)]
async fn api_get_session_recordings(
&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
}
#[oai(
path = "/sessions/:id/close",
method = "post",
operation_id = "close_session"
)]
async fn api_close_session(
&self,
state: Data<&Arc<Mutex<State>>>,
id: Path<Uuid>,
session: &poem::session::Session,
) -> ApiResult<CloseSessionResponse> {
authorized(session, || async move {
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
}
}

View file

@ -0,0 +1,73 @@
use crate::helpers::{authorized, ApiResult};
use poem::session::Session;
use poem::web::Data;
use poem_openapi::payload::Json;
use poem_openapi::{ApiResponse, OpenApi};
use sea_orm::{DatabaseConnection, EntityTrait, QueryOrder};
use std::sync::Arc;
use tokio::sync::Mutex;
use warpgate_common::{SessionSnapshot, State};
pub struct Api;
#[derive(ApiResponse)]
enum GetSessionsResponse {
#[oai(status = 200)]
Ok(Json<Vec<SessionSnapshot>>),
}
#[derive(ApiResponse)]
enum CloseAllSessionsResponse {
#[oai(status = 201)]
Ok,
}
#[OpenApi]
impl Api {
#[oai(path = "/sessions", method = "get", operation_id = "get_sessions")]
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;
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
}
#[oai(
path = "/sessions",
method = "delete",
operation_id = "close_all_sessions"
)]
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;
for s in state.sessions.values() {
let mut session = s.lock().await;
session.handle.close();
}
Ok(CloseAllSessionsResponse::Ok)
})
.await
}
}

View file

@ -0,0 +1,54 @@
use crate::helpers::{authorized, ApiResult};
use poem::session::Session;
use poem::web::Data;
use poem_openapi::payload::Json;
use poem_openapi::{ApiResponse, Object, OpenApi};
use russh_keys::PublicKeyBase64;
use serde::Serialize;
use std::sync::Arc;
use tokio::sync::Mutex;
use warpgate_common::WarpgateConfig;
pub struct Api;
#[derive(Serialize, Object)]
struct SSHKey {
pub kind: String,
pub public_key_base64: String,
}
#[derive(ApiResponse)]
enum GetSSHOwnKeysResponse {
#[oai(status = 200)]
Ok(Json<Vec<SSHKey>>),
}
#[OpenApi]
impl Api {
#[oai(
path = "/ssh/own-keys",
method = "get",
operation_id = "get_ssh_own_keys"
)]
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)?;
let keys = keys
.into_iter()
.map(|k| SSHKey {
kind: k.name().to_owned(),
public_key_base64: k.public_key_base64(),
})
.collect();
Ok(GetSSHOwnKeysResponse::Ok(Json(keys)))
})
.await
}
}

View file

@ -0,0 +1,33 @@
use crate::helpers::{authorized, ApiResult};
use poem::session::Session;
use poem::web::Data;
use poem_openapi::payload::Json;
use poem_openapi::{ApiResponse, OpenApi};
use std::sync::Arc;
use tokio::sync::Mutex;
use warpgate_common::{ConfigProvider, Target};
pub struct Api;
#[derive(ApiResponse)]
enum GetTargetsResponse {
#[oai(status = 200)]
Ok(Json<Vec<Target>>),
}
#[OpenApi]
impl Api {
#[oai(path = "/targets", method = "get", operation_id = "get_targets")]
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
}
}

View file

@ -0,0 +1,57 @@
use crate::helpers::{authorized, ApiResult};
use poem::session::Session;
use poem::web::Data;
use poem_openapi::param::Path;
use poem_openapi::{ApiResponse, OpenApi};
use sea_orm::{DatabaseConnection, EntityTrait, ModelTrait};
use std::sync::Arc;
use tokio::sync::Mutex;
use uuid::Uuid;
pub struct Api;
#[derive(ApiResponse)]
enum DeleteTicketResponse {
#[oai(status = 204)]
Deleted,
#[oai(status = 404)]
NotFound,
}
#[OpenApi]
impl Api {
#[oai(
path = "/tickets/:id",
method = "delete",
operation_id = "delete_ticket"
)]
async fn api_delete_ticket(
&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;
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),
}
})
.await
}
}

View file

@ -0,0 +1,106 @@
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};
use sea_orm::ActiveValue::Set;
use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait};
use std::sync::Arc;
use tokio::sync::Mutex;
use uuid::Uuid;
use warpgate_common::hash::generate_ticket_secret;
use warpgate_db_entities::Ticket;
pub struct Api;
#[derive(ApiResponse)]
enum GetTicketsResponse {
#[oai(status = 200)]
Ok(Json<Vec<Ticket::Model>>),
}
#[derive(Object)]
struct CreateTicketRequest {
username: String,
target_name: String,
}
#[derive(Object)]
struct TicketAndSecret {
ticket: Ticket::Model,
secret: String,
}
#[derive(ApiResponse)]
enum CreateTicketResponse {
#[oai(status = 201)]
Created(Json<TicketAndSecret>),
#[oai(status = 400)]
BadRequest(Json<String>),
}
#[OpenApi]
impl Api {
#[oai(path = "/tickets", method = "get", operation_id = "get_tickets")]
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;
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
}
#[oai(path = "/tickets", method = "post", operation_id = "create_ticket")]
async fn api_create_ticket(
&self,
db: Data<&Arc<Mutex<DatabaseConnection>>>,
body: Json<CreateTicketRequest>,
session: &Session,
) -> ApiResult<CreateTicketResponse> {
authorized(session, || async move {
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())));
}
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")?;
Ok(CreateTicketResponse::Created(Json(TicketAndSecret {
secret: secret.expose_secret().to_string(),
ticket,
})))
})
.await
}
}

View file

@ -0,0 +1,33 @@
use crate::helpers::{authorized, ApiResult};
use poem::session::Session;
use poem::web::Data;
use poem_openapi::payload::Json;
use poem_openapi::{ApiResponse, OpenApi};
use std::sync::Arc;
use tokio::sync::Mutex;
use warpgate_common::{ConfigProvider, UserSnapshot};
pub struct Api;
#[derive(ApiResponse)]
enum GetUsersResponse {
#[oai(status = 200)]
Ok(Json<Vec<UserSnapshot>>),
}
#[OpenApi]
impl Api {
#[oai(path = "/users", method = "get", operation_id = "get_users")]
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
}
}

View file

@ -0,0 +1,96 @@
//! Usage:
//!
//! ```
//! #[derive(RustEmbed)]
//! #[folder = "app/dist"]
//! pub struct Assets;
//!
//! Route::new()
//! .at("/", EmbeddedFileEndpoint::<Assets>::new("index.html"))
//! .nest_no_strip("/assets", EmbeddedFilesEndpoint::<Assets>::new())
//! ```
use async_trait::async_trait;
use poem::http::{header, Method, StatusCode};
use poem::{Endpoint, Request, Response};
use rust_embed::RustEmbed;
use std::marker::PhantomData;
pub struct EmbeddedFileEndpoint<E: RustEmbed + Send + Sync> {
_embed: PhantomData<E>,
path: String,
}
impl<E: RustEmbed + Send + Sync> EmbeddedFileEndpoint<E> {
pub fn new(path: &str) -> Self {
EmbeddedFileEndpoint {
_embed: PhantomData,
path: path.to_owned(),
}
}
}
#[async_trait]
impl<E: RustEmbed + Send + Sync> Endpoint for EmbeddedFileEndpoint<E> {
type Output = Response;
async fn call(&self, req: Request) -> Result<Self::Output, poem::Error> {
if req.method() != Method::GET {
return Err(StatusCode::METHOD_NOT_ALLOWED.into());
}
match E::get(&self.path) {
Some(content) => {
let hash = hex::encode(content.metadata.sha256_hash());
if req
.headers()
.get(header::IF_NONE_MATCH)
.map(|etag| etag.to_str().unwrap_or("000000").eq(&hash))
.unwrap_or(false)
{
return Err(StatusCode::NOT_MODIFIED.into());
}
// otherwise, return 200 with etag hash
let body: Vec<u8> = content.data.into();
let mime = mime_guess::from_path(&self.path).first_or_octet_stream();
Ok(Response::builder()
.header(header::CONTENT_TYPE, mime.as_ref())
.header(header::ETAG, hash)
.body(body))
}
None => Err(StatusCode::NOT_FOUND.into()),
}
}
}
pub struct EmbeddedFilesEndpoint<E: RustEmbed + Send + Sync> {
_embed: PhantomData<E>,
}
impl<E: RustEmbed + Send + Sync> EmbeddedFilesEndpoint<E> {
pub fn new() -> Self {
EmbeddedFilesEndpoint {
_embed: PhantomData,
}
}
}
#[async_trait]
impl<E: RustEmbed + Send + Sync> Endpoint for EmbeddedFilesEndpoint<E> {
type Output = Response;
async fn call(&self, req: Request) -> Result<Self::Output, poem::Error> {
let mut path = req
.uri()
.path()
.trim_start_matches('/')
.trim_end_matches('/')
.to_string();
if path.is_empty() {
path = "index.html".to_string();
}
let path = path.as_ref();
EmbeddedFileEndpoint::<E>::new(path).call(req).await
}
}

View file

@ -0,0 +1,28 @@
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
}

113
warpgate-admin/src/lib.rs Normal file
View file

@ -0,0 +1,113 @@
#![feature(decl_macro, proc_macro_hygiene, let_else)]
mod api;
mod embed;
mod helpers;
use crate::embed::{EmbeddedFileEndpoint, EmbeddedFilesEndpoint};
use anyhow::{Context, Result};
use poem::listener::{Listener, RustlsConfig, TcpListener};
use poem::middleware::{AddData, SetHeader};
use poem::session::{CookieConfig, MemoryStorage, ServerSession};
use poem::{EndpointExt, Route, Server};
use poem_openapi::OpenApiService;
use rust_embed::RustEmbed;
use std::net::SocketAddr;
use tracing::*;
use warpgate_common::Services;
#[derive(RustEmbed)]
#[folder = "../warpgate-admin/app/dist"]
pub struct Assets;
pub struct AdminServer {
services: Services,
}
impl AdminServer {
pub fn new(services: &Services) -> Self {
AdminServer {
services: services.clone(),
}
}
pub async fn run(self, address: SocketAddr) -> Result<()> {
let state = self.services.state.clone();
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,
),
"Warpgate",
env!("CARGO_PKG_VERSION"),
)
.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/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().cert(certificate).key(key)),
)
.run(app)
.await
.context("Failed to start admin server")
}
}

View file

@ -0,0 +1,29 @@
[package]
edition = "2021"
license = "Apache-2.0"
name = "warpgate-common"
version = "0.1.0"
[dependencies]
anyhow = "1.0"
argon2 = "0.3"
async-trait = "0.1"
bytes = "1.1"
chrono = {version = "0.4", features = ["serde"]}
data-encoding = "2.3"
humantime-serde = "1.1"
packet = "0.1"
password-hash = "0.3"
poem-openapi = {version = "1.3", features = ["swagger-ui", "chrono", "uuid", "static-files"]}
rand = "0.8"
rand_core = {version = "0.6", features = ["std"]}
sea-orm = {version = "^0.6", features = ["sqlx-sqlite", "runtime-tokio-native-tls", "macros"], default-features = false}
serde = "1.0"
serde_json = "1.0"
thiserror = "1.0"
tokio = {version = "1.17", features = ["tracing"]}
tracing = "0.1"
url = "2.2"
uuid = {version = "0.8", features = ["v4", "serde"]}
warpgate-db-entities = {version = "*", path = "../warpgate-db-entities"}
warpgate-db-migrations = {version = "*", path = "../warpgate-db-migrations"}

View file

@ -0,0 +1,42 @@
use crate::consts::TICKET_SELECTOR_PREFIX;
use crate::Secret;
use std::fmt::Debug;
pub enum AuthSelector {
User {
username: String,
target_name: String,
},
Ticket {
secret: Secret<String>,
},
}
impl From<&String> for AuthSelector {
fn from(selector: &String) -> Self {
if let Some(secret) = selector.strip_prefix(TICKET_SELECTOR_PREFIX) {
let secret = Secret::new(secret.into());
return AuthSelector::Ticket { secret };
}
let mut parts = selector.splitn(2, ':');
let username = parts.next().unwrap_or("").to_string();
let target_name = parts.next().unwrap_or("").to_string();
AuthSelector::User {
username,
target_name,
}
}
}
impl Debug for AuthSelector {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AuthSelector::User {
username,
target_name,
} => write!(f, "<{} for {}>", username, target_name),
AuthSelector::Ticket { .. } => write!(f, "<ticket>"),
}
}
}

View file

@ -0,0 +1,227 @@
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::Duration;
use crate::Secret;
const fn _default_true() -> bool {
true
}
const fn _default_false() -> bool {
false
}
const fn _default_port() -> u16 {
22
}
fn _default_username() -> String {
"root".to_owned()
}
fn _default_recordings_path() -> String {
"./data/recordings".to_owned()
}
fn _default_database_url() -> Secret<String> {
Secret::new("sqlite:data/db".to_owned())
}
fn _default_web_admin_listen() -> String {
"0.0.0.0:8888".to_owned()
}
fn _default_retention() -> Duration {
Duration::SECOND * 60 * 60 * 24 * 7
}
fn _default_empty_string_vec() -> Vec<String> {
vec![]
}
#[derive(Debug, Deserialize, Serialize, Clone, Object)]
pub struct TargetSSHOptions {
pub host: String,
#[serde(default = "_default_port")]
pub port: u16,
#[serde(default = "_default_username")]
pub username: String,
#[serde(default)]
#[oai(skip)]
pub auth: SSHTargetAuth,
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
#[serde(untagged)]
pub enum SSHTargetAuth {
#[serde(rename = "password")]
Password { password: Secret<String> },
#[serde(rename = "publickey")]
PublicKey,
}
impl Default for SSHTargetAuth {
fn default() -> Self {
SSHTargetAuth::PublicKey
}
}
#[derive(Debug, Deserialize, Serialize, Clone, Object, Default)]
pub struct TargetWebAdminOptions {}
#[derive(Debug, Deserialize, Serialize, Clone, Object)]
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>,
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
#[serde(tag = "type")]
pub enum UserAuthCredential {
#[serde(rename = "password")]
Password { hash: Secret<String> },
#[serde(rename = "publickey")]
PublicKey { key: Secret<String> },
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct User {
pub username: String,
pub credentials: Vec<UserAuthCredential>,
#[serde(skip_serializing_if = "Option::is_none")]
pub require: Option<Vec<String>>,
pub roles: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Hash)]
pub struct Role {
pub name: String,
}
fn _default_ssh_listen() -> String {
"0.0.0.0:2222".to_owned()
}
fn _default_ssh_client_key() -> String {
"./client_key".to_owned()
}
fn _default_ssh_keys_path() -> String {
"./data/keys".to_owned()
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct SSHConfig {
#[serde(default = "_default_ssh_listen")]
pub listen: String,
#[serde(default = "_default_ssh_keys_path")]
pub keys: String,
#[serde(default = "_default_ssh_client_key")]
pub client_key: String,
}
impl Default for SSHConfig {
fn default() -> Self {
SSHConfig {
listen: _default_ssh_listen(),
keys: _default_ssh_keys_path(),
client_key: _default_ssh_client_key(),
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct WebAdminConfig {
#[serde(default = "_default_false")]
pub enable: bool,
#[serde(default = "_default_web_admin_listen")]
pub listen: String,
#[serde(default)]
pub certificate: String,
#[serde(default)]
pub key: String,
}
impl Default for WebAdminConfig {
fn default() -> Self {
WebAdminConfig {
enable: true,
listen: _default_web_admin_listen(),
certificate: "".to_owned(),
key: "".to_owned(),
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct RecordingsConfig {
#[serde(default = "_default_false")]
pub enable: bool,
#[serde(default = "_default_recordings_path")]
pub path: String,
}
impl Default for RecordingsConfig {
fn default() -> Self {
Self {
enable: false,
path: _default_recordings_path(),
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct WarpgateConfigStore {
pub targets: Vec<Target>,
pub users: Vec<User>,
pub roles: Vec<Role>,
#[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 = "_default_retention", with = "humantime_serde")]
pub retention: Duration,
}
impl Default for WarpgateConfigStore {
fn default() -> Self {
Self {
targets: vec![],
users: vec![],
roles: vec![],
recordings: RecordingsConfig::default(),
web_admin: WebAdminConfig::default(),
database_url: _default_database_url(),
ssh: SSHConfig::default(),
retention: _default_retention(),
}
}
}
#[derive(Debug, Clone)]
pub struct WarpgateConfig {
pub store: WarpgateConfigStore,
pub paths_relative_to: PathBuf,
}

View file

@ -0,0 +1,226 @@
use super::ConfigProvider;
use crate::hash::verify_password_hash;
use crate::{
AuthCredential, AuthResult, Target, User, UserAuthCredential, UserSnapshot, WarpgateConfig,
};
use anyhow::Result;
use async_trait::async_trait;
use data_encoding::BASE64_MIME;
use sea_orm::ActiveValue::Set;
use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait};
use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::*;
use uuid::Uuid;
use warpgate_db_entities::Ticket;
pub struct FileConfigProvider {
db: Arc<Mutex<DatabaseConnection>>,
config: Arc<Mutex<WarpgateConfig>>,
}
impl FileConfigProvider {
pub async fn new(
db: &Arc<Mutex<DatabaseConnection>>,
config: &Arc<Mutex<WarpgateConfig>>,
) -> Self {
Self {
db: db.clone(),
config: config.clone(),
}
}
}
fn credential_is_type(c: &UserAuthCredential, k: &str) -> bool {
match c {
UserAuthCredential::Password { .. } => k == "password",
UserAuthCredential::PublicKey { .. } => k == "publickey",
}
}
#[async_trait]
impl ConfigProvider for FileConfigProvider {
async fn list_users(&mut self) -> Result<Vec<UserSnapshot>> {
Ok(self
.config
.lock()
.await
.store
.users
.iter()
.map(UserSnapshot::new)
.collect::<Vec<_>>())
}
async fn list_targets(&mut self) -> Result<Vec<Target>> {
Ok(self
.config
.lock()
.await
.store
.targets
.iter()
.map(|x| x.to_owned())
.collect::<Vec<_>>())
}
async fn authorize(
&mut self,
username: &str,
credentials: &[AuthCredential],
) -> Result<AuthResult> {
if credentials.is_empty() {
return Ok(AuthResult::Rejected);
}
let user = {
self.config
.lock()
.await
.store
.users
.iter()
.find(|x| x.username == username)
.map(User::to_owned)
};
let Some(user) = user else {
error!("Selected user not found: {}", username);
return Ok(AuthResult::Rejected);
};
let mut valid_credentials = vec![];
for client_credential in credentials {
if let AuthCredential::PublicKey {
kind,
public_key_bytes,
} = client_credential
{
let mut base64_bytes = BASE64_MIME.encode(public_key_bytes);
base64_bytes.pop();
base64_bytes.pop();
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;
}
}
}
}
}
for client_credential in credentials {
if let AuthCredential::Password(client_password) = client_credential {
for credential in user.credentials.iter() {
if let 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;
}
}
}
}
}
}
if !valid_credentials.is_empty() {
match user.require {
Some(ref required_kinds) => {
for kind in required_kinds {
if !valid_credentials
.iter()
.any(|x| credential_is_type(x, kind))
{
return Ok(AuthResult::Rejected);
}
}
return Ok(AuthResult::Accepted {
username: user.username.clone(),
});
}
None => {
return Ok(AuthResult::Accepted {
username: user.username.clone(),
})
}
}
}
warn!(username=%user.username, "Client credentials did not match");
Ok(AuthResult::Rejected)
}
async fn authorize_target(&mut self, username: &str, target_name: &str) -> Result<bool> {
let config = self.config.lock().await;
let user = config
.store
.users
.iter()
.find(|x| x.username == username)
.map(User::to_owned);
let target = config.store.targets.iter().find(|x| x.name == target_name);
let Some(user) = user else {
error!("Selected user not found: {}", username);
return Ok(false);
};
let Some(target) = target else {
error!("Selected target not found: {}", target_name);
return Ok(false);
};
let user_roles = user
.roles
.iter()
.map(|x| config.store.roles.iter().find(|y| &y.name == x))
.filter(|x| x.is_some())
.map(|x| x.unwrap().to_owned())
.collect::<HashSet<_>>();
let target_roles = target
.allow_roles
.iter()
.map(|x| config.store.roles.iter().find(|y| &y.name == x))
.filter(|x| x.is_some())
.map(|x| x.unwrap().to_owned())
.collect::<HashSet<_>>();
let intersect = user_roles.intersection(&target_roles).count() > 0;
Ok(intersect)
}
async fn consume_ticket(&mut self, ticket_id: &Uuid) -> Result<()> {
let db = self.db.lock().await;
let ticket = Ticket::Entity::find_by_id(*ticket_id).one(&*db).await?;
let Some(ticket) = ticket else {
anyhow::bail!("Ticket not found: {}", ticket_id);
};
if let Some(uses_left) = ticket.uses_left {
let mut model: Ticket::ActiveModel = ticket.into();
model.uses_left = Set(Some(uses_left - 1));
model.update(&*db).await?;
}
Ok(())
}
}

View file

@ -0,0 +1,77 @@
mod file;
use crate::{Secret, Target, UserSnapshot};
use anyhow::Result;
use async_trait::async_trait;
use bytes::Bytes;
pub use file::FileConfigProvider;
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::*;
use uuid::Uuid;
use warpgate_db_entities::Ticket;
pub enum AuthResult {
Accepted { username: String },
Rejected,
}
pub enum AuthCredential {
Password(Secret<String>),
PublicKey {
kind: String,
public_key_bytes: Bytes,
},
}
#[async_trait]
pub trait ConfigProvider {
async fn list_users(&mut self) -> Result<Vec<UserSnapshot>>;
async fn list_targets(&mut self) -> Result<Vec<Target>>;
async fn authorize(
&mut self,
username: &str,
credentials: &[AuthCredential],
) -> Result<AuthResult>;
async fn authorize_target(&mut self, username: &str, target: &str) -> Result<bool>;
async fn consume_ticket(&mut self, ticket_id: &Uuid) -> Result<()>;
}
//TODO: move this somewhere
pub async fn authorize_ticket(
db: &Arc<Mutex<DatabaseConnection>>,
secret: &Secret<String>,
) -> Result<Option<Ticket::Model>> {
let ticket = {
let db = db.lock().await;
Ticket::Entity::find()
.filter(Ticket::Column::Secret.eq(&secret.expose_secret()[..]))
.one(&*db)
.await?
};
match ticket {
Some(ticket) => {
if let Some(0) = ticket.uses_left {
warn!("Ticket is used up: {}", &ticket.id);
return Ok(None);
}
if let Some(datetime) = ticket.expiry {
if datetime < chrono::Utc::now() {
warn!("Ticket has expired: {}", &ticket.id);
return Ok(None);
}
}
Ok(Some(ticket))
}
None => {
warn!("Ticket not found: {}", &secret.expose_secret());
Ok(None)
}
}
}

View file

@ -0,0 +1 @@
pub const TICKET_SELECTOR_PREFIX: &str = "ticket-";

View file

@ -0,0 +1,45 @@
use chrono::{DateTime, Utc};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use warpgate_db_entities::Session;
use crate::{SessionId, Target, User};
#[derive(Serialize, Deserialize, Object)]
pub struct SessionSnapshot {
pub id: SessionId,
pub username: Option<String>,
pub target: Option<Target>,
pub started: DateTime<Utc>,
pub ended: Option<DateTime<Utc>>,
pub ticket_id: Option<Uuid>,
}
impl From<Session::Model> for SessionSnapshot {
fn from(model: Session::Model) -> Self {
Self {
id: model.id,
username: model.username,
target: model
.target_snapshot
.and_then(|s| serde_json::from_str(&s).ok()),
started: model.started,
ended: model.ended,
ticket_id: model.ticket_id,
}
}
}
#[derive(Serialize, Deserialize, Object)]
pub struct UserSnapshot {
pub username: String,
}
impl UserSnapshot {
pub fn new(user: &User) -> Self {
Self {
username: user.username.clone(),
}
}
}

View file

@ -0,0 +1,96 @@
use anyhow::Result;
use sea_orm::sea_query::Expr;
use sea_orm::{
ConnectOptions, Database, DatabaseConnection, EntityTrait, QueryFilter, TransactionTrait,
};
use std::time::Duration;
use warpgate_db_migrations::{Migrator, MigratorTrait};
use crate::helpers::fs::secure_file;
use crate::WarpgateConfig;
pub async fn connect_to_db(config: &WarpgateConfig) -> Result<DatabaseConnection> {
let mut url = url::Url::parse(&config.store.database_url.expose_secret()[..])?;
if url.scheme() == "sqlite" {
let path = url.path();
let mut abs_path = config.paths_relative_to.clone();
abs_path.push(path);
abs_path.push("db.sqlite3");
if let Some(parent) = abs_path.parent() {
std::fs::create_dir_all(parent)?
}
url.set_path(
abs_path
.to_str()
.ok_or_else(|| anyhow::anyhow!("Failed to convert database path to string"))?,
);
url.set_query(Some("mode=rwc"));
let db = Database::connect(ConnectOptions::new(url.to_string())).await?;
db.begin().await?.commit().await?;
drop(db);
secure_file(&abs_path)?;
}
let mut opt = ConnectOptions::new(url.to_string());
opt.max_connections(100)
.min_connections(5)
.connect_timeout(Duration::from_secs(8))
.idle_timeout(Duration::from_secs(8))
.max_lifetime(Duration::from_secs(8))
.sqlx_logging(true);
let connection = Database::connect(opt).await?;
Migrator::up(&connection, None).await?;
Ok(connection)
}
pub async fn sanitize_db(db: &mut DatabaseConnection) -> Result<()> {
use sea_orm::ActiveValue::Set;
use warpgate_db_entities::{Recording, Session};
Recording::Entity::update_many()
.set(Recording::ActiveModel {
ended: Set(Some(chrono::Utc::now())),
..Default::default()
})
.filter(Expr::col(Recording::Column::Ended).is_null())
.exec(db)
.await?;
Session::Entity::update_many()
.set(Session::ActiveModel {
ended: Set(Some(chrono::Utc::now())),
..Default::default()
})
.filter(Expr::col(Session::Column::Ended).is_null())
.exec(db)
.await?;
Ok(())
}
pub async fn cleanup_db(db: &mut DatabaseConnection, retention: &Duration) -> Result<()> {
use warpgate_db_entities::{Recording, Session};
let cutoff = chrono::Utc::now() - chrono::Duration::from_std(*retention)?;
Recording::Entity::delete_many()
.filter(Expr::col(Session::Column::Ended).is_not_null())
.filter(Expr::col(Session::Column::Ended).lt(cutoff))
.exec(db)
.await?;
Session::Entity::delete_many()
.filter(Expr::col(Session::Column::Ended).is_not_null())
.filter(Expr::col(Session::Column::Ended).lt(cutoff))
.exec(db)
.await?;
Ok(())
}

View file

@ -0,0 +1,45 @@
use std::io::Write;
use crate::UUID;
impl<B: Backend> FromSql<Binary, B> for UUID
where
Vec<u8>: FromSql<Binary, B>,
{
fn from_sql(bytes: Option<&B::RawValue>) -> diesel::deserialize::Result<Self> {
let value = <Vec<u8>>::from_sql(bytes)?;
Ok(UUID::from_bytes(&value)?)
}
}
impl<B: Backend> ToSql<Binary, B> for UUID
where
[u8]: ToSql<Binary, B>,
{
fn to_sql<W: Write>(
&self,
out: &mut diesel::serialize::Output<W, B>,
) -> diesel::serialize::Result {
let bytes = self.0.as_bytes();
<[u8] as ToSql<Binary, B>>::to_sql(bytes, out)
}
}
impl AsExpression<Binary> for UUID {
type Expression = Bound<Binary, UUID>;
fn as_expression(self) -> Self::Expression {
Bound::new(self)
}
}
impl<'a> AsExpression<Binary> for &'a UUID {
type Expression = Bound<Binary, &'a UUID>;
fn as_expression(self) -> Self::Expression {
Bound::new(self)
}
}
// impl Expression for UUID {
// type SqlType = diesel::sql_types::Binary;
// }

View file

@ -0,0 +1,94 @@
use std::sync::Arc;
use tokio::sync::mpsc::error::SendError;
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
use tokio::sync::{Mutex, MutexGuard};
pub struct EventSender<E> {
subscriptions: SubscriptionStore<E>,
}
impl<E> Clone for EventSender<E> {
fn clone(&self) -> Self {
EventSender {
subscriptions: self.subscriptions.clone(),
}
}
}
impl<E> EventSender<E> {
async fn cleanup_subscriptions(&self) -> MutexGuard<'_, SubscriptionStoreInner<E>> {
let mut subscriptions = self.subscriptions.lock().await;
subscriptions
.drain_filter(|(_, ref s)| s.is_closed())
.for_each(drop);
subscriptions
}
}
impl<'h, E: Clone + 'h> EventSender<E> {
pub async fn send_all(&'h self, event: E) -> Result<(), SendError<E>> {
let mut subscriptions = self.cleanup_subscriptions().await;
for (ref f, ref mut s) in subscriptions.iter_mut().rev() {
if f(&event) {
let _ = s.send(event.clone());
}
}
if subscriptions.is_empty() {
Err(SendError(event))
} else {
Ok(())
}
}
}
impl<'h, E: 'h> EventSender<E> {
pub async fn send_once(&'h self, event: E) -> Result<(), SendError<E>> {
let mut subscriptions = self.cleanup_subscriptions().await;
for (ref f, ref mut s) in subscriptions.iter_mut().rev() {
if f(&event) {
return s.send(event);
}
}
Err(SendError(event))
}
}
pub struct EventSubscription<E>(UnboundedReceiver<E>);
impl<E> EventSubscription<E> {
pub async fn recv(&mut self) -> Option<E> {
self.0.recv().await
}
}
type SubscriptionStoreInner<E> = Vec<(Box<dyn Fn(&E) -> bool + Send>, UnboundedSender<E>)>;
type SubscriptionStore<E> = Arc<Mutex<SubscriptionStoreInner<E>>>;
pub struct EventHub<E: Send> {
subscriptions: SubscriptionStore<E>,
}
impl<'h, E: Send> EventHub<E> {
pub fn setup() -> (Self, EventSender<E>) {
let subscriptions = Arc::new(Mutex::new(vec![]));
(
Self {
subscriptions: subscriptions.clone(),
},
EventSender { subscriptions },
)
}
pub async fn subscribe<F: Fn(&E) -> bool + Send + 'static>(
&'h self,
filter: F,
) -> EventSubscription<E> {
let (sender, receiver) = unbounded_channel();
let mut subscriptions = self.subscriptions.lock().await;
subscriptions.push((Box::new(filter), sender));
EventSubscription(receiver)
}
}

View file

@ -0,0 +1,32 @@
use crate::Secret;
use anyhow::Result;
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::Argon2;
use data_encoding::HEXLOWER;
use password_hash::errors::Error;
use rand::Rng;
pub fn hash_password(password: &str) -> String {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
argon2
.hash_password(password.as_bytes(), &salt)
.unwrap()
.to_string()
}
pub fn verify_password_hash(password: &str, hash: &str) -> Result<bool> {
let parsed_hash = PasswordHash::new(hash).map_err(|e| anyhow::anyhow!(e))?;
match Argon2::default().verify_password(password.as_bytes(), &parsed_hash) {
Ok(()) => Ok(true),
Err(Error::Password) => Ok(false),
Err(e) => Err(anyhow::anyhow!(e)),
}
}
pub fn generate_ticket_secret() -> Secret<String> {
let mut bytes = [0; 32];
rand::thread_rng().fill(&mut bytes[..]);
Secret::new(HEXLOWER.encode(&bytes))
}

View file

@ -0,0 +1,10 @@
use std::os::unix::prelude::PermissionsExt;
use std::path::Path;
pub fn secure_directory<P: AsRef<Path>>(path: P) -> std::io::Result<()> {
std::fs::set_permissions(path.as_ref(), std::fs::Permissions::from_mode(0o700))
}
pub fn secure_file<P: AsRef<Path>>(path: P) -> std::io::Result<()> {
std::fs::set_permissions(path.as_ref(), std::fs::Permissions::from_mode(0o600))
}

View file

@ -0,0 +1,2 @@
pub mod fs;
pub mod serde_base64;

View file

@ -0,0 +1,21 @@
use bytes::Bytes;
use data_encoding::BASE64;
use serde::{Deserialize, Serializer};
pub fn serialize<S>(bytes: &Bytes, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&BASE64.encode(bytes))
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Bytes, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Ok(BASE64
.decode(s.as_bytes())
.map_err(serde::de::Error::custom)?
.into())
}

View file

@ -0,0 +1,23 @@
#![feature(let_else, drain_filter, duration_constants)]
pub mod auth;
mod config;
mod config_providers;
pub mod consts;
mod data;
pub mod db;
pub mod eventhub;
pub mod hash;
pub mod helpers;
mod protocols;
pub mod recordings;
mod services;
mod state;
mod types;
pub use config::*;
pub use config_providers::*;
pub use data::*;
pub use protocols::*;
pub use services::*;
pub use state::{SessionState, State};
pub use types::*;

View file

@ -0,0 +1,90 @@
use crate::{SessionId, SessionState, State, Target};
use anyhow::{Context, Result};
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
use std::sync::Arc;
use tokio::sync::Mutex;
use warpgate_db_entities::Session;
pub trait SessionHandle {
fn close(&mut self);
}
pub struct WarpgateServerHandle {
id: SessionId,
db: Arc<Mutex<DatabaseConnection>>,
state: Arc<Mutex<State>>,
session_state: Arc<Mutex<SessionState>>,
}
impl WarpgateServerHandle {
pub fn new(
id: SessionId,
db: Arc<Mutex<DatabaseConnection>>,
state: Arc<Mutex<State>>,
session_state: Arc<Mutex<SessionState>>,
) -> Self {
WarpgateServerHandle {
id,
db,
state,
session_state,
}
}
pub fn id(&self) -> SessionId {
self.id
}
pub async fn set_username(&mut self, username: String) -> Result<()> {
use sea_orm::ActiveValue::Set;
{
self.session_state.lock().await.username = Some(username.clone())
}
let db = self.db.lock().await;
Session::Entity::update_many()
.set(Session::ActiveModel {
username: Set(Some(username)),
..Default::default()
})
.filter(Session::Column::Id.eq(self.id))
.exec(&*db)
.await?;
Ok(())
}
pub async fn set_target(&mut self, target: &Target) -> Result<()> {
use sea_orm::ActiveValue::Set;
{
self.session_state.lock().await.target = Some(target.clone());
}
let db = self.db.lock().await;
Session::Entity::update_many()
.set(Session::ActiveModel {
target_snapshot: Set(Some(
serde_json::to_string(&target).context("Error serializing target")?,
)),
..Default::default()
})
.filter(Session::Column::Id.eq(self.id))
.exec(&*db)
.await?;
Ok(())
}
}
impl Drop for WarpgateServerHandle {
fn drop(&mut self) {
let id = self.id;
let state = self.state.clone();
tokio::spawn(async move {
state.lock().await.remove_session(id).await;
});
}
}

View file

@ -0,0 +1,26 @@
mod handle;
use crate::Target;
use anyhow::Result;
use async_trait::async_trait;
pub use handle::{SessionHandle, WarpgateServerHandle};
use std::net::SocketAddr;
#[derive(Debug, thiserror::Error)]
pub enum TargetTestError {
#[error("unreachable")]
Unreachable,
#[error("authentication failed")]
AuthenticationError,
#[error("connection error")]
ConnectionError(String),
#[error("misconfigured")]
Misconfigured(String),
#[error("I/O")]
Io(#[from] std::io::Error),
}
#[async_trait]
pub trait ProtocolServer {
async fn run(self, address: SocketAddr) -> Result<()>;
async fn test_target(self, target: Target) -> Result<(), TargetTestError>;
}

View file

@ -0,0 +1,94 @@
use sea_orm::{ActiveModelTrait, DatabaseConnection};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::*;
use uuid::Uuid;
use warpgate_db_entities::Recording::{self, RecordingKind};
use crate::{RecordingsConfig, SessionId, WarpgateConfig};
mod terminal;
mod traffic;
mod writer;
pub use terminal::*;
pub use traffic::*;
use writer::RecordingWriter;
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("I/O")]
Io(#[from] std::io::Error),
#[error("Database")]
Database(#[from] sea_orm::DbErr),
#[error("Writer is closed")]
Closed,
#[error("Disabled")]
Disabled,
}
pub type Result<T> = std::result::Result<T, Error>;
pub trait Recorder {
fn kind() -> RecordingKind;
fn new(writer: RecordingWriter) -> Self;
}
pub struct SessionRecordings {
db: Arc<Mutex<DatabaseConnection>>,
path: PathBuf,
config: RecordingsConfig,
}
impl SessionRecordings {
pub fn new(db: Arc<Mutex<DatabaseConnection>>, config: &WarpgateConfig) -> Result<Self> {
let mut path = config.paths_relative_to.clone();
path.push(&config.store.recordings.path);
if config.store.recordings.enable {
std::fs::create_dir_all(&path)?;
crate::helpers::fs::secure_directory(&path)?;
}
Ok(Self {
db,
config: config.store.recordings.clone(),
path,
})
}
pub async fn start<T>(&self, id: &SessionId, name: String) -> Result<T>
where
T: Recorder,
{
if !self.config.enable {
return Err(Error::Disabled);
}
let path = self.path_for(id, &name);
tokio::fs::create_dir_all(&path.parent().unwrap()).await?;
info!(%name, path=?path, "Recording session {}", id);
let model = {
use sea_orm::ActiveValue::Set;
let values = Recording::ActiveModel {
id: Set(Uuid::new_v4()),
started: Set(chrono::Utc::now()),
session_id: Set(*id),
name: Set(name),
kind: Set(T::kind()),
..Default::default()
};
let db = self.db.lock().await;
values.insert(&*db).await.map_err(Error::Database)?
};
let writer = RecordingWriter::new(path, model, self.db.clone()).await?;
Ok(T::new(writer))
}
pub fn path_for(&self, session_id: &SessionId, name: &dyn AsRef<std::path::Path>) -> PathBuf {
self.path.join(session_id.to_string()).join(&name)
}
}

View file

@ -0,0 +1,71 @@
use anyhow::Result;
use bytes::{Bytes, BytesMut};
use serde::{Deserialize, Serialize};
use tokio::time::Instant;
use warpgate_db_entities::Recording::RecordingKind;
use super::writer::RecordingWriter;
use super::Recorder;
#[derive(Serialize, Deserialize, Debug)]
#[serde(untagged)]
pub enum TerminalRecordingItem {
Data {
time: f32,
#[serde(with = "crate::helpers::serde_base64")]
data: Bytes,
},
PtyResize {
time: f32,
cols: u32,
rows: u32,
},
}
pub struct TerminalRecorder {
writer: RecordingWriter,
started_at: Instant,
}
impl TerminalRecorder {
fn get_time(&self) -> f32 {
self.started_at.elapsed().as_secs_f32()
}
async fn write_item(&mut self, item: &TerminalRecordingItem) -> Result<()> {
let serialized_item = serde_json::to_vec(&item)?;
self.writer.write(&serialized_item).await?;
self.writer.write(b"\n").await?;
Ok(())
}
pub async fn write(&mut self, data: &[u8]) -> Result<()> {
self.write_item(&TerminalRecordingItem::Data {
time: self.get_time(),
data: BytesMut::from(data).freeze(),
})
.await
}
pub async fn write_pty_resize(&mut self, cols: u32, rows: u32) -> Result<()> {
self.write_item(&TerminalRecordingItem::PtyResize {
time: self.get_time(),
rows,
cols,
})
.await
}
}
impl Recorder for TerminalRecorder {
fn kind() -> RecordingKind {
RecordingKind::Terminal
}
fn new(writer: RecordingWriter) -> Self {
TerminalRecorder {
writer,
started_at: Instant::now(),
}
}
}

View file

@ -0,0 +1,207 @@
use std::net::Ipv4Addr;
use anyhow::Result;
use bytes::Bytes;
use packet::Builder;
use rand::Rng;
use tokio::time::Instant;
use tracing::*;
use warpgate_db_entities::Recording::RecordingKind;
use super::writer::RecordingWriter;
use super::Recorder;
pub struct TrafficRecorder {
writer: RecordingWriter,
started_at: Instant,
}
#[derive(Debug)]
pub struct TrafficConnectionParams {
pub src_addr: Ipv4Addr,
pub src_port: u16,
pub dst_addr: Ipv4Addr,
pub dst_port: u16,
}
impl TrafficRecorder {
pub fn connection(&mut self, params: TrafficConnectionParams) -> ConnectionRecorder {
ConnectionRecorder::new(params, self.writer.clone(), self.started_at)
}
}
impl Recorder for TrafficRecorder {
fn kind() -> RecordingKind {
RecordingKind::Traffic
}
fn new(writer: RecordingWriter) -> Self {
TrafficRecorder {
writer,
started_at: Instant::now(),
}
}
}
pub struct ConnectionRecorder {
params: TrafficConnectionParams,
seq_tx: u32,
seq_rx: u32,
writer: RecordingWriter,
started_at: Instant,
}
impl ConnectionRecorder {
fn new(params: TrafficConnectionParams, writer: RecordingWriter, started_at: Instant) -> Self {
Self {
params,
writer,
started_at,
seq_rx: rand::thread_rng().gen(),
seq_tx: rand::thread_rng().gen(),
}
}
pub async fn write_connection_setup(&mut self) -> Result<()> {
self.writer
.write(&[
0xd4, 0xc3, 0xb2, 0xa1, 0x02, 0, 0x04, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 0, 0,
101, 0, 0, 0,
])
.await?;
let init = self.tcp_init()?;
self.write_packet(init.0).await?;
self.write_packet(init.1).await?;
self.write_packet(init.2).await?;
Ok(())
}
async fn write_packet(&mut self, data: Bytes) -> Result<()> {
let ms = Instant::now().duration_since(self.started_at).as_micros();
self.writer
.write(&u32::to_le_bytes((ms / 10u128.pow(6)) as u32))
.await?;
self.writer
.write(&u32::to_le_bytes((ms % 10u128.pow(6)) as u32))
.await?;
self.writer
.write(&u32::to_le_bytes(data.len() as u32))
.await?;
self.writer
.write(&u32::to_le_bytes(data.len() as u32))
.await?;
self.writer.write(&data).await?;
debug!("connection {:?} data {:?}", self.params, data);
Ok(())
}
pub async fn write_rx(&mut self, data: &[u8]) -> Result<()> {
debug!("connection {:?} data tx {:?}", self.params, data);
let seq_rx = self.seq_rx;
self.seq_rx = self.seq_rx.wrapping_add(data.len() as u32);
self.write_packet(
self.tcp_packet_rx(|b| Ok(b.sequence(seq_rx)?.payload(data)?.build()?.into()))?,
)
.await?;
self.write_packet(self.tcp_packet_tx(|b| {
Ok(b.sequence(self.seq_tx)?
.acknowledgment(seq_rx + 1)?
.flags(packet::tcp::Flags::ACK)?
.build()?
.into())
})?)
.await?;
Ok(())
}
pub async fn write_tx(&mut self, data: &[u8]) -> Result<()> {
debug!("connection {:?} data tx {:?}", self.params, data);
let seq_tx = self.seq_tx;
self.seq_tx = self.seq_tx.wrapping_add(data.len() as u32);
self.write_packet(
self.tcp_packet_tx(|b| Ok(b.sequence(seq_tx)?.payload(data)?.build()?.into()))?,
)
.await?;
self.write_packet(self.tcp_packet_rx(|b| {
Ok(b.sequence(self.seq_rx)?
.acknowledgment(seq_tx + 1)?
.flags(packet::tcp::Flags::ACK)?
.build()?
.into())
})?)
.await?;
Ok(())
}
fn ip_packet_tx<F>(&self, f: F) -> Result<Bytes>
where
F: FnOnce(packet::ip::v4::Builder) -> Result<Bytes>,
{
f(packet::ip::v4::Builder::default()
.protocol(packet::ip::Protocol::Tcp)?
.source(self.params.src_addr)?
.destination(self.params.dst_addr)?)
}
fn ip_packet_rx<F>(&self, f: F) -> Result<Bytes>
where
F: FnOnce(packet::ip::v4::Builder) -> Result<Bytes>,
{
f(packet::ip::v4::Builder::default()
.protocol(packet::ip::Protocol::Tcp)?
.source(self.params.dst_addr)?
.destination(self.params.src_addr)?)
}
fn tcp_packet_tx<F>(&self, f: F) -> Result<Bytes>
where
F: FnOnce(packet::tcp::Builder) -> Result<Bytes>,
{
self.ip_packet_tx(|b| {
f(b.tcp()?
.source(self.params.src_port)?
.destination(self.params.dst_port)?)
})
}
fn tcp_packet_rx<F>(&self, f: F) -> Result<Bytes>
where
F: FnOnce(packet::tcp::Builder) -> Result<Bytes>,
{
self.ip_packet_rx(|b| {
f(b.tcp()?
.source(self.params.dst_port)?
.destination(self.params.src_port)?)
})
}
fn tcp_init(&mut self) -> Result<(Bytes, Bytes, Bytes)> {
let seq_tx = self.seq_tx;
self.seq_tx = self.seq_tx.wrapping_add(1);
let seq_rx = self.seq_rx;
self.seq_rx = self.seq_rx.wrapping_add(1);
Ok((
self.tcp_packet_tx(|b| {
Ok(b.sequence(seq_tx)?
.flags(packet::tcp::Flags::SYN)?
.build()?
.into())
})?,
self.tcp_packet_rx(|b| {
Ok(b.sequence(seq_rx)?
.acknowledgment(seq_tx + 1)?
.flags(packet::tcp::Flags::SYN | packet::tcp::Flags::ACK)?
.build()?
.into())
})?,
self.tcp_packet_tx(|b| {
Ok(b.sequence(seq_tx + 1)?
.acknowledgment(seq_rx + 1)?
.flags(packet::tcp::Flags::ACK)?
.build()?
.into())
})?,
))
}
}

View file

@ -0,0 +1,86 @@
use crate::helpers::fs::secure_file;
use super::{Error, Result};
use bytes::{Bytes, BytesMut};
use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::fs::File;
use tokio::io::{AsyncWriteExt, BufWriter};
use tokio::sync::{mpsc, Mutex};
use tracing::*;
use warpgate_db_entities::Recording;
#[derive(Clone)]
pub struct RecordingWriter {
sender: mpsc::Sender<Bytes>,
}
impl RecordingWriter {
pub(crate) async fn new(
path: PathBuf,
model: Recording::Model,
db: Arc<Mutex<DatabaseConnection>>,
) -> Result<Self> {
let file = File::create(&path).await?;
secure_file(&path)?;
let mut writer = BufWriter::new(file);
let (sender, mut receiver) = mpsc::channel::<Bytes>(1024);
tokio::spawn(async move {
if let Err(error) = async {
let mut last_flush = Instant::now();
loop {
if Instant::now() - last_flush > Duration::from_secs(5) {
last_flush = Instant::now();
writer.flush().await?;
}
tokio::select! {
data = receiver.recv() => match data {
Some(bytes) => {
writer.write_all(&bytes).await?;
}
None => break,
},
_ = tokio::time::sleep(Duration::from_millis(5000)) => ()
}
}
Ok::<(), anyhow::Error>(())
}
.await
{
error!(%error, ?path, "Failed to write recording");
}
if let Err(error) = async {
writer.flush().await?;
use sea_orm::ActiveValue::Set;
let id = model.id;
let db = db.lock().await;
let recording = Recording::Entity::find_by_id(id)
.one(&*db)
.await?
.ok_or_else(|| anyhow::anyhow!("Recording not found"))?;
let mut model: Recording::ActiveModel = recording.into();
model.ended = Set(Some(chrono::Utc::now()));
model.update(&*db).await?;
Ok::<(), anyhow::Error>(())
}
.await
{
error!(%error, ?path, "Failed to write recording");
}
});
Ok(RecordingWriter { sender })
}
pub async fn write(&mut self, data: &[u8]) -> Result<()> {
self.sender
.send(BytesMut::from(data).freeze())
.await
.map_err(|_| Error::Closed)?;
Ok(())
}
}

View file

@ -0,0 +1,40 @@
use std::sync::Arc;
use anyhow::Result;
use sea_orm::DatabaseConnection;
use tokio::sync::Mutex;
use crate::db::{connect_to_db, sanitize_db};
use crate::recordings::SessionRecordings;
use crate::{ConfigProvider, FileConfigProvider, State, WarpgateConfig};
#[derive(Clone)]
pub struct Services {
pub db: Arc<Mutex<DatabaseConnection>>,
pub recordings: Arc<Mutex<SessionRecordings>>,
pub config: Arc<Mutex<WarpgateConfig>>,
pub state: Arc<Mutex<State>>,
pub config_provider: Arc<Mutex<dyn ConfigProvider + Send + 'static>>,
}
impl Services {
pub async fn new(config: WarpgateConfig) -> Result<Self> {
let mut db = connect_to_db(&config).await?;
sanitize_db(&mut db).await?;
let db = Arc::new(Mutex::new(db));
let recordings = SessionRecordings::new(db.clone(), &config)?;
let recordings = Arc::new(Mutex::new(recordings));
let config = Arc::new(Mutex::new(config));
let config_provider = Arc::new(Mutex::new(FileConfigProvider::new(&db, &config).await));
Ok(Self {
db: db.clone(),
recordings,
config: config.clone(),
state: State::new(&db),
config_provider,
})
}
}

View file

@ -0,0 +1,102 @@
use crate::{SessionHandle, SessionId, Target, WarpgateServerHandle};
use anyhow::{Context, Result};
use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait};
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::{Arc, Weak};
use tokio::sync::Mutex;
use tracing::*;
use uuid::Uuid;
use warpgate_db_entities::Session;
pub struct State {
pub sessions: HashMap<SessionId, Arc<Mutex<SessionState>>>,
db: Arc<Mutex<DatabaseConnection>>,
this: Weak<Mutex<Self>>,
}
impl State {
pub fn new(db: &Arc<Mutex<DatabaseConnection>>) -> Arc<Mutex<Self>> {
Arc::<Mutex<Self>>::new_cyclic(|me| {
Mutex::new(Self {
sessions: HashMap::new(),
db: db.clone(),
this: me.clone(),
})
})
}
pub async fn register_session(
&mut self,
session: &Arc<Mutex<SessionState>>,
) -> Result<WarpgateServerHandle> {
let id = uuid::Uuid::new_v4();
self.sessions.insert(id, session.clone());
{
use sea_orm::ActiveValue::Set;
let values = Session::ActiveModel {
id: Set(id),
started: Set(chrono::Utc::now()),
remote_address: Set(session.lock().await.remote_address.to_string()),
..Default::default()
};
let db = self.db.lock().await;
values
.insert(&*db)
.await
.context("Error inserting session")?;
}
match self.this.upgrade() {
Some(this) => Ok(WarpgateServerHandle::new(
id,
self.db.clone(),
this,
session.clone(),
)),
None => anyhow::bail!("State is being detroyed"),
}
}
pub async fn remove_session(&mut self, id: SessionId) {
self.sessions.remove(&id);
if let Err(error) = self.mark_session_complete(id).await {
error!(%error, %id, "Could not update session in the DB");
}
}
async fn mark_session_complete(&mut self, id: Uuid) -> Result<()> {
use sea_orm::ActiveValue::Set;
let db = self.db.lock().await;
let session = Session::Entity::find_by_id(id)
.one(&*db)
.await?
.ok_or_else(|| anyhow::anyhow!("Session not found"))?;
let mut model: Session::ActiveModel = session.into();
model.ended = Set(Some(chrono::Utc::now()));
model.update(&*db).await?;
Ok(())
}
}
pub struct SessionState {
pub remote_address: 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 {
SessionState {
remote_address,
username: None,
target: None,
handle,
}
}
}

View file

@ -0,0 +1,49 @@
use serde::{Deserialize, Serialize};
use std::fmt::Debug;
use uuid::Uuid;
pub type SessionId = Uuid;
#[derive(PartialEq, Clone)]
pub struct Secret<T>(T);
impl<T> Secret<T> {
pub const fn new(v: T) -> Self {
Self(v)
}
pub fn expose_secret(&self) -> &T {
&self.0
}
}
impl<'de, T> Deserialize<'de> for Secret<T>
where
T: Deserialize<'de>,
{
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let v = Deserialize::deserialize::<D>(deserializer)?;
Ok(Self::new(v))
}
}
impl<T> Serialize for Secret<T>
where
T: Serialize,
{
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.0.serialize(serializer)
}
}
impl<T> Debug for Secret<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "<secret>")
}
}

View file

@ -0,0 +1,12 @@
[package]
edition = "2021"
license = "Apache-2.0"
name = "warpgate-db-entities"
version = "0.1.0"
[dependencies]
chrono = {version = "0.4", features = ["serde"]}
poem-openapi = {version = "1.3", features = ["chrono", "uuid"]}
sea-orm = {version = "^0.6", features = ["macros", "with-chrono", "with-uuid"], default-features = false}
serde = "1.0"
uuid = {version = "0.8", features = ["v4", "serde"]}

View file

@ -0,0 +1,21 @@
use poem_openapi::Object;
use sea_orm::entity::prelude::*;
use serde::Serialize;
use uuid::Uuid;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Object, Serialize)]
#[sea_orm(table_name = "known_hosts")]
#[oai(rename = "SSHKnownHost")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub host: String,
pub port: u16,
pub key_type: String,
pub key_base64: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -0,0 +1,63 @@
use chrono::{DateTime, Utc};
use poem_openapi::{Enum, Object};
use sea_orm::entity::prelude::*;
use sea_orm::sea_query::ForeignKeyAction;
use serde::Serialize;
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, EnumIter, Enum, DeriveActiveEnum, Serialize)]
#[sea_orm(rs_type = "String", db_type = "String(Some(16))")]
pub enum RecordingKind {
#[sea_orm(string_value = "terminal")]
Terminal,
#[sea_orm(string_value = "traffic")]
Traffic,
}
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Object)]
#[sea_orm(table_name = "recordings")]
#[oai(rename = "Recording")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub name: String,
pub started: DateTime<Utc>,
pub ended: Option<DateTime<Utc>>,
pub session_id: Uuid,
pub kind: RecordingKind,
}
// #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
// pub enum Relation {
// #[sea_orm(
// belongs_to = "super::Session::Entity",
// from = "Column::SessionId",
// to = "super::Session::Column::Id"
// )]
// Session,
// }
#[derive(Copy, Clone, Debug, EnumIter)]
pub enum Relation {
Session,
}
impl RelationTrait for Relation {
fn def(&self) -> RelationDef {
match self {
Self::Session => Entity::belongs_to(super::Session::Entity)
.from(Column::SessionId)
.to(super::Session::Column::Id)
.on_delete(ForeignKeyAction::Cascade)
.into(),
}
}
}
impl Related<super::Session::Entity> for Entity {
fn to() -> RelationDef {
Relation::Session.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -0,0 +1,46 @@
use chrono::{DateTime, Utc};
use sea_orm::entity::prelude::*;
use uuid::Uuid;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "sessions")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub target_snapshot: Option<String>,
pub username: Option<String>,
pub remote_address: String,
pub started: DateTime<Utc>,
pub ended: Option<DateTime<Utc>>,
pub ticket_id: Option<Uuid>,
}
#[derive(Copy, Clone, Debug, EnumIter)]
pub enum Relation {
Recordings,
Ticket,
}
impl RelationTrait for Relation {
fn def(&self) -> RelationDef {
match self {
Self::Recordings => Entity::has_many(super::Recording::Entity)
.from(Column::Id)
.to(super::Recording::Column::SessionId)
.into(),
Self::Ticket => Entity::belongs_to(super::Ticket::Entity)
.from(Column::TicketId)
.to(super::Ticket::Column::Id)
.on_delete(ForeignKeyAction::SetNull)
.into(),
}
}
}
impl Related<super::Ticket::Entity> for Entity {
fn to() -> RelationDef {
Relation::Ticket.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -0,0 +1,28 @@
use chrono::{DateTime, Utc};
use poem_openapi::Object;
use sea_orm::entity::prelude::*;
use serde::Serialize;
use uuid::Uuid;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Object)]
#[sea_orm(table_name = "tickets")]
#[oai(rename = "Ticket")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
#[oai(skip)]
pub secret: String,
pub username: String,
pub target: String,
pub uses_left: Option<u32>,
pub expiry: Option<DateTime<Utc>>,
pub created: DateTime<Utc>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::Session::Entity")]
Sessions,
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -0,0 +1,6 @@
#![allow(non_snake_case)]
pub mod KnownHost;
pub mod Recording;
pub mod Session;
pub mod Ticket;

View file

@ -0,0 +1,14 @@
[package]
name = "warpgate-db-migrations"
version = "0.1.0"
edition = "2021"
publish = false
license = "Apache-2.0"
[lib]
[dependencies]
sea-schema = { version = "0.5", default-features = false, features = [ "migration", "debug-print" ] }
uuid = {version = "0.8", features = ["v4", "serde"]}
chrono = "0.4"
sea-orm = {version = "^0.6", features = ["sqlx-sqlite", "runtime-tokio-native-tls", "macros"], default-features = false}

View file

@ -0,0 +1,37 @@
# Running Migrator CLI
- Apply all pending migrations
```sh
cargo run
```
```sh
cargo run -- up
```
- Apply first 10 pending migrations
```sh
cargo run -- up -n 10
```
- Rollback last applied migrations
```sh
cargo run -- down
```
- Rollback last 10 applied migrations
```sh
cargo run -- down -n 10
```
- Drop all tables from the database, then reapply all migrations
```sh
cargo run -- fresh
```
- Rollback all applied migrations, then reapply all migrations
```sh
cargo run -- refresh
```
- Rollback all applied migrations
```sh
cargo run -- reset
```
- Check the status of all migrations
```sh
cargo run -- status
```

View file

@ -0,0 +1,19 @@
pub use sea_schema::migration::*;
mod m00001_create_ticket;
mod m00002_create_session;
mod m00003_create_recording;
mod m00004_create_known_host;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![
Box::new(m00001_create_ticket::Migration),
Box::new(m00002_create_session::Migration),
Box::new(m00003_create_recording::Migration),
Box::new(m00004_create_known_host::Migration),
]
}
}

View file

@ -0,0 +1,51 @@
use sea_schema::migration::sea_orm::Schema;
use sea_schema::migration::sea_query::*;
use sea_schema::migration::*;
pub mod ticket {
use sea_orm::entity::prelude::*;
use uuid::Uuid;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "tickets")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub secret: String,
pub username: String,
pub target: String,
pub uses_left: Option<u32>,
pub expiry: Option<DateTimeUtc>,
pub created: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"m00001_create_ticket"
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let builder = manager.get_database_backend();
let schema = Schema::new(builder);
manager
.create_table(schema.create_table_from_entity(ticket::Entity))
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(ticket::Entity).to_owned())
.await
}
}

View file

@ -0,0 +1,72 @@
use sea_schema::migration::sea_orm::Schema;
use sea_schema::migration::sea_query::*;
use sea_schema::migration::*;
pub mod session {
use crate::m00001_create_ticket::ticket;
use sea_orm::entity::prelude::*;
use uuid::Uuid;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "sessions")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub target_snapshot: Option<String>,
pub username: Option<String>,
pub remote_address: String,
pub started: DateTimeUtc,
pub ended: Option<DateTimeUtc>,
pub ticket_id: Option<Uuid>,
}
#[derive(Copy, Clone, Debug, EnumIter)]
pub enum Relation {
Ticket,
}
impl RelationTrait for Relation {
fn def(&self) -> RelationDef {
match self {
Self::Ticket => Entity::belongs_to(ticket::Entity)
.from(Column::TicketId)
.to(ticket::Column::Id)
.on_delete(ForeignKeyAction::SetNull)
.into(),
}
}
}
impl Related<ticket::Entity> for Entity {
fn to() -> RelationDef {
Relation::Ticket.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
}
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"m00002_create_session"
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let builder = manager.get_database_backend();
let schema = Schema::new(builder);
manager
.create_table(schema.create_table_from_entity(session::Entity))
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(session::Entity).to_owned())
.await
}
}

View file

@ -0,0 +1,98 @@
use sea_schema::migration::sea_orm::Schema;
use sea_schema::migration::sea_query::*;
use sea_schema::migration::*;
pub mod recording {
use crate::m00002_create_session::session;
use sea_orm::entity::prelude::*;
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, EnumIter, DeriveActiveEnum)]
#[sea_orm(rs_type = "String", db_type = "String(Some(16))")]
pub enum RecordingKind {
#[sea_orm(string_value = "terminal")]
Terminal,
#[sea_orm(string_value = "traffic")]
Traffic,
}
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "recordings")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub name: String,
pub started: DateTimeUtc,
pub ended: Option<DateTimeUtc>,
pub session_id: Uuid,
pub kind: RecordingKind,
}
#[derive(Copy, Clone, Debug, EnumIter)]
pub enum Relation {
Session,
}
impl RelationTrait for Relation {
fn def(&self) -> RelationDef {
match self {
Self::Session => Entity::belongs_to(session::Entity)
.from(Column::SessionId)
.to(session::Column::Id)
.on_delete(ForeignKeyAction::Cascade)
.into(),
}
}
}
impl Related<session::Entity> for Entity {
fn to() -> RelationDef {
Relation::Session.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
}
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"m00003_create_recording"
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let builder = manager.get_database_backend();
let schema = Schema::new(builder);
manager
.create_table(schema.create_table_from_entity(recording::Entity))
.await?;
manager
.create_index(
Index::create()
.table(recording::Entity)
.name("recording__unique__session_id__name")
.unique()
.col(recording::Column::SessionId)
.col(recording::Column::Name)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_index(
Index::drop()
.name("recording__unique__session_id__name")
.to_owned(),
)
.await?;
manager
.drop_table(Table::drop().table(recording::Entity).to_owned())
.await
}
}

Some files were not shown because too many files have changed in this diff Show more