mirror of
https://github.com/warp-tech/warpgate.git
synced 2024-09-20 06:46:17 +08:00
import
This commit is contained in:
commit
4ccf2b0437
2
.cargo/config
Normal file
2
.cargo/config
Normal file
|
@ -0,0 +1,2 @@
|
|||
[build]
|
||||
rustflags = ["--cfg", "tokio_unstable"]
|
67
.github/workflows/build.yml
vendored
Normal file
67
.github/workflows/build.yml
vendored
Normal 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
20
.github/workflows/dependency-review.yml
vendored
Normal 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
18
.github/workflows/release.yml
vendored
Normal 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
20
.gitignore
vendored
Normal 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
4312
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
15
Cargo.toml
Normal file
15
Cargo.toml
Normal 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
201
LICENSE
Normal 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
33
README.md
Normal 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
198
deny.toml
Normal 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
27
justfile
Normal 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
|
2
rust-toolchain.toml
Normal file
2
rust-toolchain.toml
Normal file
|
@ -0,0 +1,2 @@
|
|||
[toolchain]
|
||||
channel = "nightly-2022-03-14"
|
1
rustfmt.toml
Normal file
1
rustfmt.toml
Normal file
|
@ -0,0 +1 @@
|
|||
imports_granularity = "Module"
|
28
warpgate-admin/Cargo.toml
Normal file
28
warpgate-admin/Cargo.toml
Normal 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"}
|
9
warpgate-admin/app/.editorconfig
Normal file
9
warpgate-admin/app/.editorconfig
Normal 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
|
140
warpgate-admin/app/.eslintrc.yaml
Normal file
140
warpgate-admin/app/.eslintrc.yaml
Normal 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
28
warpgate-admin/app/.gitignore
vendored
Normal 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
|
13
warpgate-admin/app/index.html
Normal file
13
warpgate-admin/app/index.html
Normal 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>
|
669
warpgate-admin/app/openapi-schema.json
Normal file
669
warpgate-admin/app/openapi-schema.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
7
warpgate-admin/app/openapitools.json
Normal file
7
warpgate-admin/app/openapitools.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"$schema": "node_modules/@openapitools/openapi-generator-cli/config.schema.json",
|
||||
"spaces": 2,
|
||||
"generator-cli": {
|
||||
"version": "5.4.0"
|
||||
}
|
||||
}
|
51
warpgate-admin/app/package.json
Normal file
51
warpgate-admin/app/package.json
Normal 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"
|
||||
}
|
||||
}
|
42
warpgate-admin/app/public/assets/logo.svg
Normal file
42
warpgate-admin/app/public/assets/logo.svg
Normal 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 |
136
warpgate-admin/app/src/App.svelte
Normal file
136
warpgate-admin/app/src/App.svelte
Normal 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>
|
108
warpgate-admin/app/src/CreateTicket.svelte
Normal file
108
warpgate-admin/app/src/CreateTicket.svelte
Normal 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}
|
110
warpgate-admin/app/src/Home.svelte
Normal file
110
warpgate-admin/app/src/Home.svelte
Normal 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>
|
80
warpgate-admin/app/src/Login.svelte
Normal file
80
warpgate-admin/app/src/Login.svelte
Normal 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>
|
49
warpgate-admin/app/src/Recording.svelte
Normal file
49
warpgate-admin/app/src/Recording.svelte
Normal 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>
|
6
warpgate-admin/app/src/RelativeDate.svelte
Normal file
6
warpgate-admin/app/src/RelativeDate.svelte
Normal file
|
@ -0,0 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { timeAgo } from 'lib/time'
|
||||
export let date: any
|
||||
</script>
|
||||
|
||||
<span title={date}>{timeAgo(date)}</span>
|
73
warpgate-admin/app/src/SSH.svelte
Normal file
73
warpgate-admin/app/src/SSH.svelte
Normal 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>
|
123
warpgate-admin/app/src/Session.svelte
Normal file
123
warpgate-admin/app/src/Session.svelte
Normal 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>
|
111
warpgate-admin/app/src/Targets.svelte
Normal file
111
warpgate-admin/app/src/Targets.svelte
Normal 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>
|
71
warpgate-admin/app/src/Tickets.svelte
Normal file
71
warpgate-admin/app/src/Tickets.svelte
Normal 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>
|
BIN
warpgate-admin/app/src/assets/svelte.png
Normal file
BIN
warpgate-admin/app/src/assets/svelte.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.1 KiB |
8
warpgate-admin/app/src/lib/api.ts
Normal file
8
warpgate-admin/app/src/lib/api.ts
Normal 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'
|
5
warpgate-admin/app/src/lib/ssh.ts
Normal file
5
warpgate-admin/app/src/lib/ssh.ts
Normal 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}`
|
||||
}
|
3
warpgate-admin/app/src/lib/store.ts
Normal file
3
warpgate-admin/app/src/lib/store.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import { writable } from 'svelte/store'
|
||||
|
||||
export const authenticatedUsername = writable<string|null>(null)
|
5
warpgate-admin/app/src/lib/time.ts
Normal file
5
warpgate-admin/app/src/lib/time.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import moment from 'moment'
|
||||
|
||||
export function timeAgo(t: any): string {
|
||||
return moment(t).fromNow()
|
||||
}
|
9
warpgate-admin/app/src/main.ts
Normal file
9
warpgate-admin/app/src/main.ts
Normal 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
|
92
warpgate-admin/app/src/theme.scss
Normal file
92
warpgate-admin/app/src/theme.scss
Normal 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;
|
||||
}
|
11
warpgate-admin/app/src/vars.scss
Normal file
11
warpgate-admin/app/src/vars.scss
Normal 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
5
warpgate-admin/app/src/vite-env.d.ts
vendored
Normal 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
|
13
warpgate-admin/app/svelte.config.js
Normal file
13
warpgate-admin/app/svelte.config.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
import sveltePreprocess from 'svelte-preprocess'
|
||||
|
||||
export default {
|
||||
compilerOptions: {
|
||||
enableSourcemap: true,
|
||||
},
|
||||
preprocess: sveltePreprocess({
|
||||
sourceMap: true,
|
||||
}),
|
||||
experimental: {
|
||||
prebundleSvelteLibraries: true,
|
||||
},
|
||||
}
|
37
warpgate-admin/app/tsconfig.json
Normal file
37
warpgate-admin/app/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
8
warpgate-admin/app/tsconfig.node.json
Normal file
8
warpgate-admin/app/tsconfig.node.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
16
warpgate-admin/app/vite.config.ts
Normal file
16
warpgate-admin/app/vite.config.ts
Normal 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
2607
warpgate-admin/app/yarn.lock
Normal file
File diff suppressed because it is too large
Load diff
74
warpgate-admin/src/api/auth.rs
Normal file
74
warpgate-admin/src/api/auth.rs
Normal 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)
|
||||
}
|
||||
}
|
30
warpgate-admin/src/api/info.rs
Normal file
30
warpgate-admin/src/api/info.rs
Normal 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"),
|
||||
})))
|
||||
}
|
||||
}
|
56
warpgate-admin/src/api/known_hosts_detail.rs
Normal file
56
warpgate-admin/src/api/known_hosts_detail.rs
Normal 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
|
||||
}
|
||||
}
|
43
warpgate-admin/src/api/known_hosts_list.rs
Normal file
43
warpgate-admin/src/api/known_hosts_list.rs
Normal 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
|
||||
}
|
||||
}
|
12
warpgate-admin/src/api/mod.rs
Normal file
12
warpgate-admin/src/api/mod.rs
Normal 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;
|
191
warpgate-admin/src/api/recordings_detail.rs
Normal file
191
warpgate-admin/src/api/recordings_detail.rs
Normal 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 {
|
||||
|
||||
// })
|
||||
// }
|
111
warpgate-admin/src/api/sessions_detail.rs
Normal file
111
warpgate-admin/src/api/sessions_detail.rs
Normal 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
|
||||
}
|
||||
}
|
73
warpgate-admin/src/api/sessions_list.rs
Normal file
73
warpgate-admin/src/api/sessions_list.rs
Normal 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
|
||||
}
|
||||
}
|
54
warpgate-admin/src/api/ssh_keys.rs
Normal file
54
warpgate-admin/src/api/ssh_keys.rs
Normal 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
|
||||
}
|
||||
}
|
33
warpgate-admin/src/api/targets_list.rs
Normal file
33
warpgate-admin/src/api/targets_list.rs
Normal 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
|
||||
}
|
||||
}
|
57
warpgate-admin/src/api/tickets_detail.rs
Normal file
57
warpgate-admin/src/api/tickets_detail.rs
Normal 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
|
||||
}
|
||||
}
|
106
warpgate-admin/src/api/tickets_list.rs
Normal file
106
warpgate-admin/src/api/tickets_list.rs
Normal 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
|
||||
}
|
||||
}
|
33
warpgate-admin/src/api/users_list.rs
Normal file
33
warpgate-admin/src/api/users_list.rs
Normal 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
|
||||
}
|
||||
}
|
96
warpgate-admin/src/embed.rs
Normal file
96
warpgate-admin/src/embed.rs
Normal 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
|
||||
}
|
||||
}
|
28
warpgate-admin/src/helpers.rs
Normal file
28
warpgate-admin/src/helpers.rs
Normal 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
113
warpgate-admin/src/lib.rs
Normal 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")
|
||||
}
|
||||
}
|
29
warpgate-common/Cargo.toml
Normal file
29
warpgate-common/Cargo.toml
Normal 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"}
|
42
warpgate-common/src/auth.rs
Normal file
42
warpgate-common/src/auth.rs
Normal 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>"),
|
||||
}
|
||||
}
|
||||
}
|
227
warpgate-common/src/config.rs
Normal file
227
warpgate-common/src/config.rs
Normal 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,
|
||||
}
|
226
warpgate-common/src/config_providers/file.rs
Normal file
226
warpgate-common/src/config_providers/file.rs
Normal 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(())
|
||||
}
|
||||
}
|
77
warpgate-common/src/config_providers/mod.rs
Normal file
77
warpgate-common/src/config_providers/mod.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
1
warpgate-common/src/consts.rs
Normal file
1
warpgate-common/src/consts.rs
Normal file
|
@ -0,0 +1 @@
|
|||
pub const TICKET_SELECTOR_PREFIX: &str = "ticket-";
|
45
warpgate-common/src/data.rs
Normal file
45
warpgate-common/src/data.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
96
warpgate-common/src/db/mod.rs
Normal file
96
warpgate-common/src/db/mod.rs
Normal 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(())
|
||||
}
|
45
warpgate-common/src/db/uuid.rs
Normal file
45
warpgate-common/src/db/uuid.rs
Normal 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;
|
||||
// }
|
94
warpgate-common/src/eventhub.rs
Normal file
94
warpgate-common/src/eventhub.rs
Normal 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)
|
||||
}
|
||||
}
|
32
warpgate-common/src/hash.rs
Normal file
32
warpgate-common/src/hash.rs
Normal 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))
|
||||
}
|
10
warpgate-common/src/helpers/fs.rs
Normal file
10
warpgate-common/src/helpers/fs.rs
Normal 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))
|
||||
}
|
2
warpgate-common/src/helpers/mod.rs
Normal file
2
warpgate-common/src/helpers/mod.rs
Normal file
|
@ -0,0 +1,2 @@
|
|||
pub mod fs;
|
||||
pub mod serde_base64;
|
21
warpgate-common/src/helpers/serde_base64.rs
Normal file
21
warpgate-common/src/helpers/serde_base64.rs
Normal 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())
|
||||
}
|
23
warpgate-common/src/lib.rs
Normal file
23
warpgate-common/src/lib.rs
Normal 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::*;
|
90
warpgate-common/src/protocols/handle.rs
Normal file
90
warpgate-common/src/protocols/handle.rs
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
26
warpgate-common/src/protocols/mod.rs
Normal file
26
warpgate-common/src/protocols/mod.rs
Normal 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>;
|
||||
}
|
94
warpgate-common/src/recordings/mod.rs
Normal file
94
warpgate-common/src/recordings/mod.rs
Normal 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)
|
||||
}
|
||||
}
|
71
warpgate-common/src/recordings/terminal.rs
Normal file
71
warpgate-common/src/recordings/terminal.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
207
warpgate-common/src/recordings/traffic.rs
Normal file
207
warpgate-common/src/recordings/traffic.rs
Normal 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())
|
||||
})?,
|
||||
))
|
||||
}
|
||||
}
|
86
warpgate-common/src/recordings/writer.rs
Normal file
86
warpgate-common/src/recordings/writer.rs
Normal 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(())
|
||||
}
|
||||
}
|
40
warpgate-common/src/services.rs
Normal file
40
warpgate-common/src/services.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
102
warpgate-common/src/state.rs
Normal file
102
warpgate-common/src/state.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
49
warpgate-common/src/types.rs
Normal file
49
warpgate-common/src/types.rs
Normal 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>")
|
||||
}
|
||||
}
|
12
warpgate-db-entities/Cargo.toml
Normal file
12
warpgate-db-entities/Cargo.toml
Normal 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"]}
|
21
warpgate-db-entities/src/KnownHost.rs
Normal file
21
warpgate-db-entities/src/KnownHost.rs
Normal 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 {}
|
63
warpgate-db-entities/src/Recording.rs
Normal file
63
warpgate-db-entities/src/Recording.rs
Normal 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 {}
|
46
warpgate-db-entities/src/Session.rs
Normal file
46
warpgate-db-entities/src/Session.rs
Normal 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 {}
|
28
warpgate-db-entities/src/Ticket.rs
Normal file
28
warpgate-db-entities/src/Ticket.rs
Normal 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 {}
|
6
warpgate-db-entities/src/lib.rs
Normal file
6
warpgate-db-entities/src/lib.rs
Normal file
|
@ -0,0 +1,6 @@
|
|||
#![allow(non_snake_case)]
|
||||
|
||||
pub mod KnownHost;
|
||||
pub mod Recording;
|
||||
pub mod Session;
|
||||
pub mod Ticket;
|
14
warpgate-db-migrations/Cargo.toml
Normal file
14
warpgate-db-migrations/Cargo.toml
Normal 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}
|
37
warpgate-db-migrations/README.md
Normal file
37
warpgate-db-migrations/README.md
Normal 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
|
||||
```
|
19
warpgate-db-migrations/src/lib.rs
Normal file
19
warpgate-db-migrations/src/lib.rs
Normal 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),
|
||||
]
|
||||
}
|
||||
}
|
51
warpgate-db-migrations/src/m00001_create_ticket.rs
Normal file
51
warpgate-db-migrations/src/m00001_create_ticket.rs
Normal 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
|
||||
}
|
||||
}
|
72
warpgate-db-migrations/src/m00002_create_session.rs
Normal file
72
warpgate-db-migrations/src/m00002_create_session.rs
Normal 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
|
||||
}
|
||||
}
|
98
warpgate-db-migrations/src/m00003_create_recording.rs
Normal file
98
warpgate-db-migrations/src/m00003_create_recording.rs
Normal 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
Loading…
Reference in a new issue