added e2e tests

This commit is contained in:
Eugene Pankov 2022-08-14 12:36:49 +02:00
parent 1a9bd89b44
commit c5cf5bf1d1
No known key found for this signature in database
GPG key ID: 5896FCBBDD1CF4F4
51 changed files with 2500 additions and 17 deletions

7
.flake8 Normal file
View file

@ -0,0 +1,7 @@
[flake8]
ignore=E501,D103,C901,D203,W504,S607,S603,S404,S606,S322,S410,S320,B010
exclude = .git,__pycache__,help,static,misc,locale,templates,tests,deployment,migrations,elements/ai/scripts
max-complexity = 40
builtins = _
per-file-ignores = scripts/*:T001,E402
select = C,E,F,W,B,B902

55
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,55 @@
name: Test
on: [push, pull_request]
jobs:
Tests:
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 build deps
run: |
sudo apt-get install openssh-client expect
cargo install just
cargo install cargo-llvm-cov
cargo clean
rustup component add llvm-tools-preview
- name: Build admin UI
run: |
just yarn
just openapi
just yarn build
- name: Build images
working-directory: tests
run: |
make all
- name: Install deps
working-directory: tests
run: |
pip3 install poetry
poetry install
- name: Run
working-directory: tests
run: |
poetry run ./run.sh
cargo llvm-cov --no-run --hide-instantiations --lcov > coverage.lcov
- name: SonarCloud Scan
uses: SonarSource/sonarcloud-github-action@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

2
.gitignore vendored
View file

@ -18,3 +18,5 @@ host_key*
data
config.*.yaml
config.yaml
__pycache__
.pytest_cache

View file

@ -1,3 +1,5 @@
cargo-features = ["profile-rustflags"]
[workspace]
members = [
"warpgate",
@ -18,3 +20,7 @@ default-members = ["warpgate"]
lto = true
panic = "abort"
strip = "debuginfo"
[profile.coverage]
inherits = "dev"
rustflags = ["-Cinstrument-coverage"]

7
sonar-project.properties Normal file
View file

@ -0,0 +1,7 @@
sonar.projectKey=warp-tech_warpgate
sonar.organization=warp-tech
sonar.sources=.
sonar.inclusions=warpgate-*/**/*
sonar.cfamily.llvm-cov.reportPath=coverage.lcov

7
tests/Makefile Normal file
View file

@ -0,0 +1,7 @@
image-ssh-server:
cd images/ssh-server && docker build -t warpgate-e2e-ssh-server .
image-mysql-server:
cd images/mysql-server && docker build -t warpgate-e2e-mysql-server .
all: image-ssh-server image-mysql-server

0
tests/__init__.py Normal file
View file

View file

@ -0,0 +1,10 @@
-----BEGIN CERTIFICATE-----
MIIBYjCCAQmgAwIBAgIJAKXIp8GepnCzMAoGCCqGSM49BAMCMCExHzAdBgNVBAMM
FnJjZ2VuIHNlbGYgc2lnbmVkIGNlcnQwIBcNNzUwMTAxMDAwMDAwWhgPNDA5NjAx
MDEwMDAwMDBaMCExHzAdBgNVBAMMFnJjZ2VuIHNlbGYgc2lnbmVkIGNlcnQwWTAT
BgcqhkjOPQIBBggqhkjOPQMBBwNCAARAtRfTqyH8+eXf12Vftm6VcMhhYG6Ape3O
tcLfIWJo1krsOP+96r5U20ya7YVVFmYFPoQToAOoio2dxlX3jOL/oygwJjAkBgNV
HREEHTAbgg53YXJwZ2F0ZS5sb2NhbIIJbG9jYWxob3N0MAoGCCqGSM49BAMCA0cA
MEQCICTt3I/PsgF8Rvu6aKwY2LTouZyxReDMiCePzsqdAxXAAiATNw61MBylNaAF
FGkPqR0VZIR6sIFHZnib9JQNhka2Fg==
-----END CERTIFICATE-----

5
tests/certs/tls.key.pem Normal file
View file

@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg0tJmr/OSF7neTQOV
gQn+qHCdVsOENdMc86RlWPiWDlKhRANCAARAtRfTqyH8+eXf12Vftm6VcMhhYG6A
pe3OtcLfIWJo1krsOP+96r5U20ya7YVVFmYFPoQToAOoio2dxlX3jOL/
-----END PRIVATE KEY-----

273
tests/conftest.py Normal file
View file

@ -0,0 +1,273 @@
import logging
import os
import psutil
import pytest
import requests
import shutil
import signal
import subprocess
import tempfile
import urllib3
import uuid
from dataclasses import dataclass
from pathlib import Path
from textwrap import dedent
from typing import List
from .util import alloc_port
from .test_http_common import http_common_wg_port, echo_server_port # noqa
cargo_root = Path(os.getcwd()).parent
@dataclass
class Context:
tmpdir: Path
@dataclass
class Child:
process: subprocess.Popen
stop_signal: signal.Signals
stop_timeout: float
class ProcessManager:
children: List[Child]
def __init__(self, ctx: Context) -> None:
self.children = []
self.ctx = ctx
def stop(self):
for child in self.children:
try:
p = psutil.Process(child.process.pid)
except psutil.NoSuchProcess:
continue
p.send_signal(child.stop_signal)
for sp in p.children(recursive=True):
try:
sp.terminate()
except psutil.NoSuchProcess:
pass
try:
p.wait(timeout=child.stop_timeout)
except psutil.TimeoutExpired:
for sp in p.children(recursive=True):
try:
sp.kill()
except psutil.NoSuchProcess:
pass
p.kill()
def start_ssh_server(self, trusted_keys=[]):
port = alloc_port()
data_dir = self.ctx.tmpdir / f'sshd-{uuid.uuid4()}'
data_dir.mkdir(parents=True)
authorized_keys_path = data_dir / 'authorized_keys'
authorized_keys_path.write_text('\n'.join(trusted_keys))
config_path = data_dir / 'sshd_config'
config_path.write_text(
dedent(
f'''\
Port 22
AuthorizedKeysFile {authorized_keys_path}
AllowAgentForwarding yes
AllowTcpForwarding yes
GatewayPorts yes
X11Forwarding yes
UseDNS no
PermitTunnel yes
StrictModes no
PermitRootLogin yes
HostKey /ssh-keys/id_ed25519
Subsystem sftp /usr/lib/ssh/sftp-server
'''
)
)
data_dir.chmod(0o700)
authorized_keys_path.chmod(0o600)
config_path.chmod(0o600)
self.start(
[
'docker',
'run',
'--rm',
'-p',
f'{port}:22',
'-v',
f'{data_dir}:{data_dir}',
'-v',
f'{os.getcwd()}/ssh-keys:/ssh-keys',
'warpgate-e2e-ssh-server',
'-f',
str(config_path),
]
)
return port
def start_mysql_server(self):
port = alloc_port()
self.start(
[
'docker',
'run',
'--rm',
'-p',
f'{port}:3306',
'warpgate-e2e-mysql-server'
]
)
return port
def start_wg(self, config='', args=None):
ssh_port = alloc_port()
http_port = alloc_port()
mysql_port = alloc_port()
data_dir = self.ctx.tmpdir / f'wg-data-{uuid.uuid4()}'
data_dir.mkdir(parents=True)
keys_dir = data_dir / 'keys'
keys_dir.mkdir(parents=True)
for k in [
Path('ssh-keys/wg/client-ed25519'),
Path('ssh-keys/wg/client-rsa'),
Path('ssh-keys/wg/host-ed25519'),
Path('ssh-keys/wg/host-rsa'),
Path('certs/tls.certificate.pem'),
Path('certs/tls.key.pem'),
]:
shutil.copy(k, keys_dir / k.name)
config_path = data_dir / 'warpgate.yaml'
config_path.write_text(
dedent(
f'''\
ssh:
enable: true
listen: 0.0.0.0:{ssh_port}
keys: {keys_dir}
host_key_verification: auto_accept
http:
enable: true
listen: 0.0.0.0:{http_port}
certificate: {keys_dir}/tls.certificate.pem
key: {keys_dir}/tls.key.pem
mysql:
enable: true
listen: 0.0.0.0:{mysql_port}
certificate: {keys_dir}/tls.certificate.pem
key: {keys_dir}/tls.key.pem
recordings:
enable: false
roles:
- name: role
- name: admin
'''
) + config
)
args = args or ['run']
p = self.start(
[
f'{cargo_root}/target/llvm-cov-target/debug/warpgate',
'--config',
str(config_path),
*args,
],
cwd=cargo_root,
env={
**os.environ,
'LLVM_PROFILE_FILE': f'{cargo_root}/target/llvm-cov-target/warpgate-%m.profraw',
},
stop_signal=signal.SIGINT,
stop_timeout=5,
)
return p, {
'ssh': ssh_port,
'http': http_port,
'mysql': mysql_port,
}
def start_ssh_client(self, *args, password=None, **kwargs):
preargs = []
if password:
preargs = ['sshpass', '-p', password]
p = self.start(
[
*preargs,
'ssh',
# '-v',
'-o',
'IdentitiesOnly=yes',
'-o',
'StrictHostKeychecking=no',
'-o',
'UserKnownHostsFile=/dev/null',
*args,
],
stdout=subprocess.PIPE,
**kwargs,
)
return p
def start(self, args, stop_timeout=3, stop_signal=signal.SIGTERM, **kwargs):
p = subprocess.Popen(args, **kwargs)
self.children.append(Child(process=p, stop_signal=stop_signal, stop_timeout=stop_timeout))
return p
@pytest.fixture(scope='session')
def ctx():
with tempfile.TemporaryDirectory() as tmpdir:
ctx = Context(tmpdir=Path(tmpdir))
yield ctx
@pytest.fixture(scope='session')
def processes(ctx, report_generation):
mgr = ProcessManager(ctx)
try:
yield mgr
finally:
mgr.stop()
@pytest.fixture(scope='session', autouse=True)
def report_generation():
# subprocess.call(['cargo', 'llvm-cov', 'clean', '--workspace'])
subprocess.check_call(['cargo', 'llvm-cov', 'run', '--no-report', '--', '--version'], cwd=cargo_root)
yield
# subprocess.check_call(['cargo', 'llvm-cov', '--no-run', '--hide-instantiations', '--html'], cwd=cargo_root)
# ----
@pytest.fixture(scope='session')
def wg_c_ed25519_pubkey():
return Path(os.getcwd()) / 'ssh-keys/wg/client-ed25519.pub'
@pytest.fixture(scope='session')
def otp_key_base64():
return 'Isj0ekwF1YsKW8VUUQiU4awp/9dMnyMcTPH9rlr1OsE='
@pytest.fixture(scope='session')
def otp_key_base32():
return 'ELEPI6SMAXKYWCS3YVKFCCEU4GWCT76XJSPSGHCM6H624WXVHLAQ'
@pytest.fixture(scope='session')
def password_123_hash():
return '$argon2id$v=19$m=4096,t=3,p=1$cxT6YKZS7r3uBT4nPJXEJQ$GhjTXyGi5vD2H/0X8D3VgJCZSXM4I8GiXRzl4k5ytk0'
logging.basicConfig(level=logging.DEBUG)
requests.packages.urllib3.disable_warnings()
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
subprocess.call('chmod 600 ssh-keys/id*', shell=True)

View file

@ -0,0 +1,6 @@
FROM mariadb:10.8
ENV MYSQL_DATABASE=db
ENV MYSQL_ROOT_PASSWORD=123
ADD init.sql /docker-entrypoint-initdb.d

View file

@ -0,0 +1,4 @@
CREATE TABLE `db`.`table` (
`id` int(11) NOT NULL,
`name` varchar(1023) NOT NULL
) ENGINE=InnoDB;

View file

@ -0,0 +1,4 @@
FROM alpine:3.14
RUN apk add openssh
RUN passwd -u root
ENTRYPOINT ["/usr/sbin/sshd", "-De"]

553
tests/poetry.lock generated Normal file
View file

@ -0,0 +1,553 @@
[[package]]
name = "atomicwrites"
version = "1.4.1"
description = "Atomic file writes."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "attrs"
version = "21.4.0"
description = "Classes Without Boilerplate"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[package.extras]
dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"]
docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"]
tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"]
[[package]]
name = "bcrypt"
version = "3.2.2"
description = "Modern password hashing for your software and your servers"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
cffi = ">=1.1"
[package.extras]
tests = ["pytest (>=3.2.1,!=3.3.0)"]
typecheck = ["mypy"]
[[package]]
name = "certifi"
version = "2022.6.15"
description = "Python package for providing Mozilla's CA Bundle."
category = "main"
optional = false
python-versions = ">=3.6"
[[package]]
name = "cffi"
version = "1.15.1"
description = "Foreign Function Interface for Python calling C code."
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
pycparser = "*"
[[package]]
name = "charset-normalizer"
version = "2.1.0"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
category = "main"
optional = false
python-versions = ">=3.6.0"
[package.extras]
unicode_backport = ["unicodedata2"]
[[package]]
name = "click"
version = "8.1.3"
description = "Composable command line interface toolkit"
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
[[package]]
name = "colorama"
version = "0.4.5"
description = "Cross-platform colored terminal text."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "cryptography"
version = "37.0.4"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
cffi = ">=1.12"
[package.extras]
docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"]
docstest = ["pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"]
pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"]
sdist = ["setuptools_rust (>=0.11.4)"]
ssh = ["bcrypt (>=3.1.5)"]
test = ["pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"]
[[package]]
name = "flake8"
version = "5.0.2"
description = "the modular source code checker: pep8 pyflakes and co"
category = "dev"
optional = false
python-versions = ">=3.6.1"
[package.dependencies]
importlib-metadata = {version = "<4.3", markers = "python_version < \"3.8\""}
mccabe = ">=0.7.0,<0.8.0"
pycodestyle = ">=2.9.0,<2.10.0"
pyflakes = ">=2.5.0,<2.6.0"
[[package]]
name = "flask"
version = "2.2.1"
description = "A simple framework for building complex web applications."
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
click = ">=8.0"
importlib-metadata = {version = ">=3.6.0", markers = "python_version < \"3.10\""}
itsdangerous = ">=2.0"
Jinja2 = ">=3.0"
Werkzeug = ">=2.2.0"
[package.extras]
async = ["asgiref (>=3.2)"]
dotenv = ["python-dotenv"]
[[package]]
name = "flask-sock"
version = "0.5.2"
description = "WebSocket support for Flask"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
flask = ">=2"
simple-websocket = ">=0.5.1"
[[package]]
name = "h11"
version = "0.13.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
[[package]]
name = "idna"
version = "3.3"
description = "Internationalized Domain Names in Applications (IDNA)"
category = "main"
optional = false
python-versions = ">=3.5"
[[package]]
name = "importlib-metadata"
version = "4.2.0"
description = "Read metadata from Python packages"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""}
zipp = ">=0.5"
[package.extras]
docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"]
[[package]]
name = "iniconfig"
version = "1.1.1"
description = "iniconfig: brain-dead simple config-ini parsing"
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "itsdangerous"
version = "2.1.2"
description = "Safely pass data to untrusted environments and back."
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "jinja2"
version = "3.1.2"
description = "A very fast and expressive template engine."
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
MarkupSafe = ">=2.0"
[package.extras]
i18n = ["Babel (>=2.7)"]
[[package]]
name = "markupsafe"
version = "2.1.1"
description = "Safely add untrusted strings to HTML/XML markup."
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "mccabe"
version = "0.7.0"
description = "McCabe checker, plugin for flake8"
category = "dev"
optional = false
python-versions = ">=3.6"
[[package]]
name = "packaging"
version = "21.3"
description = "Core utilities for Python packages"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
[[package]]
name = "paramiko"
version = "2.11.0"
description = "SSH2 protocol library"
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
bcrypt = ">=3.1.3"
cryptography = ">=2.5"
pynacl = ">=1.0.1"
six = "*"
[package.extras]
all = ["pyasn1 (>=0.1.7)", "pynacl (>=1.0.1)", "bcrypt (>=3.1.3)", "invoke (>=1.3)", "gssapi (>=1.4.1)", "pywin32 (>=2.1.8)"]
ed25519 = ["pynacl (>=1.0.1)", "bcrypt (>=3.1.3)"]
gssapi = ["pyasn1 (>=0.1.7)", "gssapi (>=1.4.1)", "pywin32 (>=2.1.8)"]
invoke = ["invoke (>=1.3)"]
[[package]]
name = "pluggy"
version = "1.0.0"
description = "plugin and hook calling mechanisms for python"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
[package.extras]
testing = ["pytest-benchmark", "pytest"]
dev = ["tox", "pre-commit"]
[[package]]
name = "psutil"
version = "5.9.1"
description = "Cross-platform lib for process and system monitoring in Python."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[package.extras]
test = ["ipaddress", "mock", "enum34", "pywin32", "wmi"]
[[package]]
name = "py"
version = "1.11.0"
description = "library with cross-python path, ini-parsing, io, code, log facilities"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "pycodestyle"
version = "2.9.0"
description = "Python style guide checker"
category = "dev"
optional = false
python-versions = ">=3.6"
[[package]]
name = "pycparser"
version = "2.21"
description = "C parser in Python"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "pyflakes"
version = "2.5.0"
description = "passive checker of Python programs"
category = "dev"
optional = false
python-versions = ">=3.6"
[[package]]
name = "pynacl"
version = "1.5.0"
description = "Python binding to the Networking and Cryptography (NaCl) library"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
cffi = ">=1.4.1"
[package.extras]
docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"]
tests = ["pytest (>=3.2.1,!=3.3.0)", "hypothesis (>=3.27.0)"]
[[package]]
name = "pyotp"
version = "2.6.0"
description = "Python One Time Password Library"
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "pyparsing"
version = "3.0.9"
description = "pyparsing module - Classes and methods to define and execute parsing grammars"
category = "main"
optional = false
python-versions = ">=3.6.8"
[package.extras]
diagrams = ["railroad-diagrams", "jinja2"]
[[package]]
name = "pytest"
version = "7.1.2"
description = "pytest: simple powerful testing with Python"
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
attrs = ">=19.2.0"
colorama = {version = "*", markers = "sys_platform == \"win32\""}
importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
iniconfig = "*"
packaging = "*"
pluggy = ">=0.12,<2.0"
py = ">=1.8.2"
tomli = ">=1.0.0"
[package.extras]
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
[[package]]
name = "requests"
version = "2.28.1"
description = "Python HTTP for Humans."
category = "main"
optional = false
python-versions = ">=3.7, <4"
[package.dependencies]
certifi = ">=2017.4.17"
charset-normalizer = ">=2,<3"
idna = ">=2.5,<4"
urllib3 = ">=1.21.1,<1.27"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "simple-websocket"
version = "0.8.0"
description = "Simple WebSocket server and client for Python"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
wsproto = "*"
[[package]]
name = "six"
version = "1.16.0"
description = "Python 2 and 3 compatibility utilities"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]]
name = "ssh2-python"
version = "1.0.0"
description = "Bindings for libssh2 C library"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "tomli"
version = "2.0.1"
description = "A lil' TOML parser"
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "typing-extensions"
version = "4.3.0"
description = "Backported and Experimental Type Hints for Python 3.7+"
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "urllib3"
version = "1.26.11"
description = "HTTP library with thread-safe connection pooling, file post, and more."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4"
[package.extras]
brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"]
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
[[package]]
name = "websocket-client"
version = "1.3.3"
description = "WebSocket client for Python with low level API options"
category = "main"
optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["Sphinx (>=3.4)", "sphinx-rtd-theme (>=0.5)"]
optional = ["python-socks", "wsaccel"]
test = ["websockets"]
[[package]]
name = "werkzeug"
version = "2.2.1"
description = "The comprehensive WSGI web application library."
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
MarkupSafe = ">=2.1.1"
[package.extras]
watchdog = ["watchdog"]
[[package]]
name = "wsproto"
version = "1.1.0"
description = "WebSockets state-machine based protocol implementation"
category = "main"
optional = false
python-versions = ">=3.7.0"
[package.dependencies]
h11 = ">=0.9.0,<1"
[[package]]
name = "zipp"
version = "3.8.1"
description = "Backport of pathlib-compatible object wrapper for zip files"
category = "main"
optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "jaraco.tidelift (>=1.4)"]
testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"]
[metadata]
lock-version = "1.1"
python-versions = "^3.7"
content-hash = "5f35fb40904521418ed8cf0d0c2bbe36011c91f7aaeb672603572d930ff42763"
[metadata.files]
atomicwrites = []
attrs = []
bcrypt = []
certifi = []
cffi = []
charset-normalizer = []
click = []
colorama = []
cryptography = []
flake8 = []
flask = []
flask-sock = []
h11 = []
idna = []
importlib-metadata = []
iniconfig = [
{file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
]
itsdangerous = []
jinja2 = []
markupsafe = []
mccabe = []
packaging = []
paramiko = []
pluggy = []
psutil = []
py = []
pycodestyle = []
pycparser = []
pyflakes = []
pynacl = []
pyotp = []
pyparsing = []
pytest = []
requests = []
simple-websocket = []
six = []
ssh2-python = []
tomli = []
typing-extensions = []
urllib3 = []
websocket-client = []
werkzeug = []
wsproto = []
zipp = []

29
tests/pyproject.toml Normal file
View file

@ -0,0 +1,29 @@
[tool.poetry]
name = "tests"
version = "0.1.0"
description = ""
authors = ["Your Name <you@example.com>"]
[tool.poetry.dependencies]
python = "^3.7"
pytest = "^7.1.2"
psutil = "^5.9.1"
pyotp = "^2.6.0"
paramiko = "^2.11.0"
Flask = "^2.2.1"
requests = "^2.28.1"
flask-sock = "^0.5.2"
websocket-client = "^1.3.3"
[tool.poetry.dev-dependencies]
flake8 = "^5.0.2"
pytest = "^7.1.2"
ssh2-python = "^1.0.0"
[tool.pytest.ini_options]
minversion = "6.0"
filterwarnings = ["ignore::urllib3.exceptions.InsecureRequestWarning"]
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"

9
tests/run.sh Executable file
View file

@ -0,0 +1,9 @@
#!/bin/sh
set -e
cd ..
rm target/llvm-cov-target/* || true
cargo llvm-cov clean --workspace
cargo llvm-cov --no-report --workspace
cd tests
poetry run pytest $@
cargo llvm-cov --no-run --hide-instantiations --html

View file

@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACAz/wkEtOQGGLxHmd1+hD0hOJYri7j+8Oqex4CMnTr26gAAAKC8ieiIvIno
iAAAAAtzc2gtZWQyNTUxOQAAACAz/wkEtOQGGLxHmd1+hD0hOJYri7j+8Oqex4CMnTr26g
AAAEBe+1fXsb/WtCsxt6nR5fVqIX9WHQqbpiVxxNTy41IsFDP/CQS05AYYvEeZ3X6EPSE4
liuLuP7w6p7HgIydOvbqAAAAHGV1Z2VuZUBFdWdlbmVzLU1CUC5mcml0ei5ib3gB
-----END OPENSSH PRIVATE KEY-----

View file

@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDP/CQS05AYYvEeZ3X6EPSE4liuLuP7w6p7HgIydOvbq

38
tests/ssh-keys/id_rsa Normal file
View file

@ -0,0 +1,38 @@
-----BEGIN OPENSSH PRIVATE KEY-----
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
-----END OPENSSH PRIVATE KEY-----

View file

@ -0,0 +1 @@
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCd30zPwWdhmb+lcHUn7djHwgOxvy+6CAaYT/Elmb8fS+4IPi8D9oOzeB2pcuNTX+7IPQn8JA8fga82oCQnwCK8zZWGcWwbVAkuSWRcQd4EWVPzWbE7a3QMHhLpBBeBtgLte3JjqBWpFi4xP5ahqfMlxgy/eBIYWv9ifxadViXffOwPxi0uXcq7vX7mTKQUL/z8uXE5RjvdfNM9W4KERO/PsqEquUGIeE5DCPLHYHUl0ZCnmFKsFNKY7f4iYgywKWxDDd8/S9zuy3svTp44E1OuE86JCqPKfkygJ+ghEC7q4TugfKuwt9iJmslbJLP5N1lk150p9DFYNcFwrn7SnxxjJL9T7IYdGtKNIe2X6qpLxBgJN2W15H8QNK487vITre1Gk5GDovmSq4C6mt4fqu2A7QenP/pleP1uwBH4az4nozHOslZ4OIDFQCS+D1qdiCPeyQMUGx/KVV7Z57LV9C3HT+KIo2MXKumoc8BQjIldrlBOmWlk+Jwoylr5ffyrqB8=

View file

@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACB8c0a+GzS7QQTMt4qvepvzMRZx9t5OvKj2o2HxmOVMAQAAAKAK02MACtNj
AAAAAAtzc2gtZWQyNTUxOQAAACB8c0a+GzS7QQTMt4qvepvzMRZx9t5OvKj2o2HxmOVMAQ
AAAEDuH0qssNc+ANqiQp0MsgyLECAPmDjkUeNpUbahI+AagnxzRr4bNLtBBMy3iq96m/Mx
FnH23k68qPajYfGY5UwBAAAAHGV1Z2VuZUBFdWdlbmVzLU1CUC5mcml0ei5ib3gB
-----END OPENSSH PRIVATE KEY-----

View file

@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHxzRr4bNLtBBMy3iq96m/MxFnH23k68qPajYfGY5UwB eugene@Eugenes-MBP.fritz.box

View file

@ -0,0 +1,45 @@
-----BEGIN PRIVATE KEY-----
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
-----END PRIVATE KEY-----

View file

@ -0,0 +1,6 @@
-----BEGIN PRIVATE KEY-----
MHMCAQEwBQYDK2VwBEIEQBHw4cXPpGgA+KdvPF5gxrzML+oa3yQk0JzIbWvmqM5H30RyBF8GrOWz
p77UAd3O4PgYzzFcUc79g8yKtbKhzJGhIwMhAN9EcgRfBqzls6e+1AHdzuD4GM8xXFHO/YPMirWy
ocyR
-----END PRIVATE KEY-----

View file

@ -0,0 +1,45 @@
-----BEGIN PRIVATE KEY-----
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
-----END PRIVATE KEY-----

46
tests/test_http_basic.py Normal file
View file

@ -0,0 +1,46 @@
import requests
from .util import wait_port
class Test:
def test_basic(
self,
http_common_wg_port,
):
wait_port(http_common_wg_port, recv=False)
session = requests.Session()
session.verify = False
url = f'https://localhost:{http_common_wg_port}'
response = session.get(f'{url}/?warpgate-target=echo', allow_redirects=False)
assert response.status_code == 307
redirect = response.headers['location']
assert redirect == '/@warpgate#/login?next=%2F%3Fwarpgate%2Dtarget%3Decho'
response = session.get(f'{url}/@warpgate/api/info').json()
assert response['username'] is None
response = session.post(
f'{url}/@warpgate/api/auth/login',
json={
'username': 'user',
'password': '123',
},
)
assert response.status_code == 201
response = session.get(f'{url}/@warpgate/api/info').json()
assert response['username'] == 'user'
response = session.get(
f'{url}/some/path?a=b&warpgate-target=echo&c=d', allow_redirects=False
)
print(response)
print(response.text)
assert response.status_code == 200
assert response.json()['method'] == 'GET'
assert response.json()['path'] == '/some/path'
assert response.json()['args']['a'] == 'b'
assert response.json()['args']['c'] == 'd'

91
tests/test_http_common.py Normal file
View file

@ -0,0 +1,91 @@
import pytest
import threading
from textwrap import dedent
from .util import alloc_port
@pytest.fixture(scope='session')
def http_common_wg_port(processes, echo_server_port, password_123_hash, otp_key_base64):
_, wg_ports = processes.start_wg(
dedent(
f'''\
targets:
- name: echo
allow_roles: [role]
http:
url: http://localhost:{echo_server_port}
- name: baddomain
allow_roles: [role]
http:
url: http://localhostfoobar
- name: admin
allow_roles: [admin]
web_admin: {{}}
users:
- username: admin
roles: [admin]
credentials:
- type: password
hash: '{password_123_hash}'
- username: user
roles: [role]
credentials:
- type: password
hash: '{password_123_hash}'
- username: userwithotp
roles: [role]
credentials:
- type: password
hash: '{password_123_hash}'
- type: otp
key: {otp_key_base64}
require:
http: [password, otp]
'''
),
)
yield wg_ports['http']
@pytest.fixture(scope='session')
def echo_server_port():
from flask import Flask, request, jsonify, redirect
from flask_sock import Sock
app = Flask(__name__)
sock = Sock(app)
@app.route('/set-cookie')
def set_cookie():
response = jsonify({})
response.set_cookie('cookie', 'value')
return response
@app.route('/redirect/<path:url>')
def r(url):
return redirect(url)
@app.route('/', defaults={'path': ''})
@app.route('/<path:path>')
def echo(path):
return jsonify({
'method': request.method,
'args': request.args,
'path': request.path,
})
@sock.route('/socket')
def ws_echo(ws):
while True:
data = ws.receive()
ws.send(data)
port = alloc_port()
def runner():
app.run(port=port, load_dotenv=False)
thread = threading.Thread(target=runner, daemon=True)
thread.start()
yield port

View file

@ -0,0 +1,41 @@
from textwrap import dedent
class Test:
def test_success(
self,
processes,
echo_server_port,
):
proc, _ = processes.start_wg(
config=dedent(
f'''\
users: []
targets:
- name: target
allow_roles: [role]
http:
url: http://localhost:{echo_server_port}
'''
),
args=['test-target', 'target'],
)
proc.wait(timeout=5)
assert proc.returncode == 0
def test_fail_no_connection(self, processes):
proc, _ = processes.start_wg(
config=dedent(
'''\
users: []
targets:
- name: target
allow_roles: [role]
http:
url: http://localhostbaddomain
'''
),
args=['test-target', 'target'],
)
proc.wait(timeout=5)
assert proc.returncode != 0

View file

@ -0,0 +1,31 @@
import requests
from .util import wait_port
class TestHTTPCookies:
def test(
self,
http_common_wg_port,
):
wait_port(http_common_wg_port, recv=False)
session = requests.Session()
session.verify = False
url = f'https://localhost:{http_common_wg_port}'
headers = {'Host': f'localhost:{http_common_wg_port}'}
session.post(
f'{url}/@warpgate/api/auth/login',
json={
'username': 'user',
'password': '123',
},
headers=headers,
)
response = session.get(f'{url}/set-cookie?warpgate-target=echo', headers=headers)
print(response.headers)
cookies = session.cookies.get_dict()
assert cookies['cookie'] == 'value'

View file

@ -0,0 +1,30 @@
import requests
from .util import wait_port
class TestHTTPRedirects:
def test(
self,
http_common_wg_port,
echo_server_port,
):
wait_port(http_common_wg_port, recv=False)
session = requests.Session()
session.verify = False
url = f'https://localhost:{http_common_wg_port}'
headers = {'Host': f'localhost:{http_common_wg_port}'}
session.post(
f'{url}/@warpgate/api/auth/login',
json={
'username': 'user',
'password': '123',
},
headers=headers,
)
response = session.get(f'{url}/redirect/http://localhost:{echo_server_port}/test?warpgate-target=echo', headers=headers, allow_redirects=False)
print(response.headers)
assert response.headers['location'] == '/test'

View file

@ -0,0 +1,35 @@
import requests
from .util import wait_port
class Test:
def test(
self,
http_common_wg_port,
):
wait_port(http_common_wg_port, recv=False)
session = requests.Session()
session.verify = False
url = f'https://localhost:{http_common_wg_port}'
response = session.post(
f'{url}/@warpgate/api/auth/login',
json={
'username': 'user',
'password': '123',
},
)
assert response.status_code // 100 == 2
response = session.get(
f'{url}/some/path?a=b&warpgate-target=echo&c=d', allow_redirects=False
)
assert response.status_code // 100 == 2
assert response.json()['path'] == '/some/path'
response = session.post(f'{url}/@warpgate/api/auth/logout')
response = session.get(f'{url}/?warpgate-target=echo', allow_redirects=False)
assert response.status_code // 100 != 2

View file

@ -0,0 +1,78 @@
import requests
import pyotp
from .util import wait_port
class TestHTTPUserAuthOTP:
def test_auth_otp_success(
self,
http_common_wg_port,
otp_key_base32,
):
wait_port(http_common_wg_port, recv=False)
session = requests.Session()
session.verify = False
url = f'https://localhost:{http_common_wg_port}'
totp = pyotp.TOTP(otp_key_base32)
response = session.post(
f'{url}/@warpgate/api/auth/login',
json={
'username': 'userwithotp',
'password': '123',
},
)
assert response.status_code // 100 != 2
response = session.get(
f'{url}/some/path?a=b&warpgate-target=echo&c=d', allow_redirects=False
)
assert response.status_code // 100 != 2
response = session.post(
f'{url}/@warpgate/api/auth/otp',
json={
'otp': totp.now(),
},
)
assert response.status_code // 100 == 2
response = session.get(
f'{url}/some/path?a=b&warpgate-target=echo&c=d', allow_redirects=False
)
assert response.status_code // 100 == 2
assert response.json()['path'] == '/some/path'
def test_auth_otp_fail(
self,
http_common_wg_port,
):
wait_port(http_common_wg_port, recv=False)
session = requests.Session()
session.verify = False
url = f'https://localhost:{http_common_wg_port}'
response = session.post(
f'{url}/@warpgate/api/auth/login',
json={
'username': 'userwithotp',
'password': '123',
},
)
assert response.status_code // 100 != 2
response = session.post(
f'{url}/@warpgate/api/auth/otp',
json={
'otp': '00000000',
},
)
assert response.status_code // 100 != 2
response = session.get(
f'{url}/some/path?a=b&warpgate-target=echo&c=d', allow_redirects=False
)
assert response.status_code // 100 != 2

View file

@ -0,0 +1,56 @@
import requests
from .util import wait_port
class TestHTTPUserAuthPassword:
def test_auth_password_success(
self,
http_common_wg_port,
):
wait_port(http_common_wg_port, recv=False)
session = requests.Session()
session.verify = False
url = f'https://localhost:{http_common_wg_port}'
response = session.get(f'{url}/?warpgate-target=echo', allow_redirects=False)
assert response.status_code // 100 != 2
response = session.post(
f'{url}/@warpgate/api/auth/login',
json={
'username': 'user',
'password': '123',
},
)
assert response.status_code // 100 == 2
response = session.get(
f'{url}/some/path?a=b&warpgate-target=echo&c=d', allow_redirects=False
)
assert response.status_code // 100 == 2
assert response.json()['path'] == '/some/path'
def test_auth_password_fail(
self,
http_common_wg_port,
):
wait_port(http_common_wg_port, recv=False)
session = requests.Session()
session.verify = False
url = f'https://localhost:{http_common_wg_port}'
response = session.post(
f'{url}/@warpgate/api/auth/login',
json={
'username': 'user',
'password': '321321',
},
)
assert response.status_code // 100 != 2
response = session.get(
f'{url}/some/path?a=b&warpgate-target=echo&c=d', allow_redirects=False
)
assert response.status_code // 100 != 2

View file

@ -0,0 +1,56 @@
import requests
from .util import create_ticket, wait_port
class TestHTTPUserAuthTicket:
def test_auth_password_success(
self,
http_common_wg_port,
):
wait_port(http_common_wg_port, recv=False)
url = f'https://localhost:{http_common_wg_port}'
secret = create_ticket(url, 'user', 'echo')
# ---
session = requests.Session()
session.verify = False
response = session.get(
f'{url}/some/path?warpgate-target=echo',
allow_redirects=False,
)
assert response.status_code // 100 != 2
# Ticket as a header
response = session.get(
f'{url}/some/path?warpgate-target=echo',
allow_redirects=False,
headers={
'Authorization': f'Warpgate {secret}',
},
)
assert response.status_code // 100 == 2
assert response.json()['path'] == '/some/path'
# Ticket as a GET param
session = requests.Session()
session.verify = False
response = session.get(
f'{url}/some/path?warpgate-ticket={secret}',
allow_redirects=False,
)
assert response.status_code // 100 == 2
assert response.json()['path'] == '/some/path'
# Ensure no access to other targets
session = requests.Session()
session.verify = False
response = session.get(
f'{url}/some/path?warpgate-ticket={secret}&warpgate-target=admin',
allow_redirects=False,
)
assert response.status_code // 100 == 2
assert response.json()['path'] == '/some/path'

View file

@ -0,0 +1,39 @@
import ssl
import requests
from websocket import create_connection
from .util import wait_port
class TestHTTPWebsocket:
def test_basic(
self,
http_common_wg_port,
):
wait_port(http_common_wg_port, recv=False)
session = requests.Session()
session.verify = False
url = f'https://localhost:{http_common_wg_port}'
session.post(
f'{url}/@warpgate/api/auth/login',
json={
'username': 'user',
'password': '123',
},
)
cookies = session.cookies.get_dict()
cookie = '; '.join([f'{k}={v}' for k, v in cookies.items()])
ws = create_connection(
f'wss://localhost:{http_common_wg_port}/socket?warpgate-target=echo',
cookie=cookie,
sslopt={"cert_reqs": ssl.CERT_NONE},
)
ws.send('test')
assert ws.recv() == 'test'
ws.send_binary(b'test')
assert ws.recv() == b'test'
ws.ping()
ws.close()

View file

@ -0,0 +1,74 @@
import subprocess
from textwrap import dedent
from .conftest import ProcessManager
from .util import wait_port, wait_mysql_port, mysql_client_ssl_opt
class Test:
def test(self, processes: ProcessManager, password_123_hash):
db_port = processes.start_mysql_server()
_, wg_ports = processes.start_wg(
dedent(
f'''\
targets:
- name: db
allow_roles: [role]
mysql:
host: localhost
port: {db_port}
user: root
password: '123'
users:
- username: user
roles: [role]
credentials:
- type: password
hash: '{password_123_hash}'
'''
),
)
wait_mysql_port(db_port)
wait_port(wg_ports['mysql'])
client = processes.start(
[
'mysql',
'--user',
'user#db',
'-p123',
'--host',
'127.0.0.1',
'--port',
str(wg_ports["mysql"]),
'--enable-cleartext-plugin',
mysql_client_ssl_opt,
'db',
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
)
assert b'\ndb\n' in client.communicate(b'show schemas;')[0]
assert client.returncode == 0
client = processes.start(
[
'mysql',
'--user',
'user:db',
'-pwrong',
'--host',
'127.0.0.1',
'--port',
str(wg_ports["mysql"]),
'--enable-cleartext-plugin',
mysql_client_ssl_opt,
'db',
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
)
client.communicate(b'show schemas;')
assert client.returncode != 0

View file

@ -0,0 +1,50 @@
from pathlib import Path
from textwrap import dedent
from .conftest import ProcessManager
from .util import alloc_port, wait_port
class Test:
def test_success(
self, processes: ProcessManager, wg_c_ed25519_pubkey: Path
):
ssh_port = processes.start_ssh_server(
trusted_keys=[wg_c_ed25519_pubkey.read_text()]
)
wait_port(ssh_port)
proc, _ = processes.start_wg(
config=dedent(
f'''\
users: []
targets:
- name: ssh
allow_roles: [role]
ssh:
host: localhost
port: {ssh_port}
'''
),
args=['test-target', 'ssh'],
)
proc.wait(timeout=5)
assert proc.returncode == 0
def test_fail(self, processes: ProcessManager):
ssh_port = alloc_port()
proc, _ = processes.start_wg(
config=dedent(
f'''\
users: []
targets:
- name: ssh
allow_roles: [role]
ssh:
host: localhost
port: {ssh_port}
'''
),
args=['test-target', 'ssh'],
)
proc.wait(timeout=5)
assert proc.returncode != 0

221
tests/test_ssh_proto.py Normal file
View file

@ -0,0 +1,221 @@
import subprocess
import tempfile
import pytest
from textwrap import dedent
from .conftest import ProcessManager
from .util import wait_port, alloc_port
@pytest.fixture(scope='class')
def ssh_port(processes, wg_c_ed25519_pubkey):
yield processes.start_ssh_server(trusted_keys=[wg_c_ed25519_pubkey.read_text()])
@pytest.fixture(scope='class')
def wg_port(processes, ssh_port, password_123_hash):
_, wg_ports = processes.start_wg(
dedent(
f'''\
targets:
- name: ssh
allow_roles: [role]
ssh:
host: localhost
port: {ssh_port}
- name: ssh-bad-domain
allow_roles: [role]
ssh:
host: baddomainsomething
users:
- username: user
roles: [role]
credentials:
- type: password
hash: '{password_123_hash}'
- type: publickey
key: {open('ssh-keys/id_rsa.pub').read().strip()}
'''
),
)
wait_port(ssh_port)
wait_port(wg_ports['ssh'])
yield wg_ports['ssh']
common_args = [
'user:ssh@localhost',
'-i',
'/dev/null',
'-o',
'PreferredAuthentications=password',
]
class Test:
def test_stdout_stderr(
self,
processes: ProcessManager,
wg_port,
):
ssh_client = processes.start_ssh_client(
'-p',
str(wg_port),
*common_args,
'sh',
'-c',
'"echo -n stdout; echo -n stderr >&2"',
password='123',
stderr=subprocess.PIPE,
)
stdout, stderr = ssh_client.communicate()
assert b'stdout' == stdout
assert stderr.endswith(b'stderr')
def test_pty(
self,
processes: ProcessManager,
wg_port,
):
ssh_client = processes.start_ssh_client(
'-p',
str(wg_port),
'-tt',
*common_args,
'echo',
'hello',
password='123',
)
output = ssh_client.communicate()[0]
assert b'Warpgate' in output
assert b'Selected target:' in output
assert b'hello\r\n' in output
def test_signals(
self,
processes: ProcessManager,
wg_port,
):
ssh_client = processes.start_ssh_client(
'-p',
str(wg_port),
'-v',
*common_args,
'sh', '-c',
'"pkill -9 sh"',
password='123',
)
assert ssh_client.returncode != 0
def test_direct_tcpip(
self,
processes: ProcessManager,
wg_port,
):
local_port = alloc_port()
ssh_client = processes.start_ssh_client(
'-p',
str(wg_port),
'-v',
*common_args,
'-L', f'{local_port}:localhost:22',
'sleep', '15',
password='123',
)
data = wait_port(local_port)
assert b'SSH-2.0' in data
ssh_client.kill()
def test_shell(
self,
processes: ProcessManager,
wg_port,
):
script = dedent(
f'''
set timeout 10
spawn ssh -tt user:ssh@localhost -p {wg_port} -o StrictHostKeychecking=no -o UserKnownHostsFile=/dev/null -o PreferredAuthentications=password
expect "password:"
sleep 0.5
send "123\\r"
expect "#"
sleep 0.5
send "ls /bin/sh\\r"
send "exit\\r"
expect {{
"/bin/sh" {{ exit 0; }}
eof {{ exit 1; }}
}}
exit 1
'''
)
ssh_client = processes.start(
['expect', '-d'], stdin=subprocess.PIPE, stdout=subprocess.PIPE
)
output = ssh_client.communicate(script.encode())[0]
assert ssh_client.returncode == 0, output
def test_connection_error(
self,
processes: ProcessManager,
wg_port,
):
ssh_client = processes.start_ssh_client(
'-p',
str(wg_port),
'-tt',
'user:ssh-bad-domain@localhost',
'-i',
'/dev/null',
'-o',
'PreferredAuthentications=password',
'echo',
'hello',
password='123',
stderr=subprocess.PIPE,
)
stdout = ssh_client.communicate()[0]
assert b'Selected target: ssh-bad-domain' in stdout
assert ssh_client.returncode != 0
def test_sftp(
self,
wg_port,
):
with tempfile.TemporaryDirectory() as f:
subprocess.check_call(
[
'sftp',
'-P',
str(wg_port),
'-o',
'User=user:ssh',
'-o',
'IdentitiesOnly=yes',
'-o',
'IdentityFile=ssh-keys/id_rsa',
'-o',
'PreferredAuthentications=publickey',
'-o',
'StrictHostKeychecking=no',
'-o',
'UserKnownHostsFile=/dev/null',
'localhost:/etc/passwd',
f,
],
stdout=subprocess.PIPE,
)
assert 'root:x:0:0:root' in open(f + '/passwd').read()

View file

@ -0,0 +1,52 @@
from pathlib import Path
from textwrap import dedent
from .conftest import ProcessManager
from .util import wait_port
class Test:
def test_bad_target(
self, processes: ProcessManager, wg_c_ed25519_pubkey: Path, password_123_hash
):
ssh_port = processes.start_ssh_server(
trusted_keys=[wg_c_ed25519_pubkey.read_text()]
)
_, wg_ports = processes.start_wg(
dedent(
f'''\
targets:
- name: ssh
allow_roles: [role]
ssh:
host: localhost
port: {ssh_port}
users:
- username: user
roles: [role]
credentials:
- type: password
hash: '{password_123_hash}'
'''
),
)
wait_port(ssh_port)
wait_port(wg_ports['ssh'])
ssh_client = processes.start_ssh_client(
'-t',
'user:badtarget@localhost',
'-p',
str(wg_ports['ssh']),
'-i',
'/dev/null',
'-o',
'PreferredAuthentications=password',
'echo',
'hello',
password='123',
)
assert ssh_client.returncode != 0

View file

@ -0,0 +1,97 @@
from asyncio import subprocess
import pyotp
from pathlib import Path
from textwrap import dedent
from .conftest import ProcessManager
from .util import wait_port
class Test:
def test_otp(
self,
processes: ProcessManager,
wg_c_ed25519_pubkey: Path,
otp_key_base32: str,
otp_key_base64: str,
):
ssh_port = processes.start_ssh_server(
trusted_keys=[wg_c_ed25519_pubkey.read_text()]
)
_, wg_ports = processes.start_wg(
dedent(
f'''\
targets:
- name: ssh
allow_roles: [role]
ssh:
host: localhost
port: {ssh_port}
users:
- username: user
roles: [role]
credentials:
- type: publickey
key: {open('ssh-keys/id_ed25519.pub').read().strip()}
- type: otp
key: {otp_key_base64}
require:
ssh: [publickey, otp]
'''
),
)
wait_port(ssh_port)
wait_port(wg_ports['ssh'])
totp = pyotp.TOTP(otp_key_base32)
script = dedent(
f'''
set timeout 10
spawn ssh user:ssh@localhost -p {wg_ports['ssh']} -o StrictHostKeychecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes -o IdentityFile=ssh-keys/id_ed25519 -o PreferredAuthentications=publickey,keyboard-interactive ls /bin/sh
expect "Two-factor authentication"
sleep 0.5
send "{totp.now()}\\r"
expect {{
"/bin/sh" {{ exit 0; }}
eof {{ exit 1; }}
}}
'''
)
ssh_client = processes.start(
['expect'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
output, stderr = ssh_client.communicate(script.encode())
assert ssh_client.returncode == 0, output + stderr
script = dedent(
f'''
set timeout 10
spawn ssh user:ssh@localhost -p {[wg_ports['ssh']]} -o StrictHostKeychecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes -o IdentityFile=ssh-keys/id_ed25519 -o PreferredAuthentications=publickey,keyboard-interactive ls /bin/sh
expect "Two-factor authentication"
sleep 0.5
send "12345678\\r"
expect {{
"/bin/sh" {{ exit 0; }}
"Two-factor authentication" {{ exit 1; }}
eof {{ exit 1; }}
}}
'''
)
ssh_client = processes.start(
['expect'], stdin=subprocess.PIPE, stdout=subprocess.PIPE
)
output = ssh_client.communicate(script.encode())[0]
assert ssh_client.returncode != 0, output

View file

@ -0,0 +1,66 @@
from pathlib import Path
from textwrap import dedent
from .conftest import ProcessManager
from .util import wait_port
class Test:
def test(
self, processes: ProcessManager, wg_c_ed25519_pubkey: Path, password_123_hash
):
ssh_port = processes.start_ssh_server(
trusted_keys=[wg_c_ed25519_pubkey.read_text()]
)
_, wg_ports = processes.start_wg(
dedent(
f'''\
targets:
- name: ssh
allow_roles: [role]
ssh:
host: localhost
port: {ssh_port}
users:
- username: user
roles: [role]
credentials:
- type: password
hash: '{password_123_hash}'
'''
),
)
wait_port(ssh_port)
wait_port(wg_ports['ssh'])
ssh_client = processes.start_ssh_client(
'user:ssh@localhost',
'-p',
str(wg_ports['ssh']),
'-i',
'/dev/null',
'-o',
'PreferredAuthentications=password',
'ls',
'/bin/sh',
password='123',
)
assert ssh_client.communicate()[0] == b'/bin/sh\n'
assert ssh_client.returncode == 0
ssh_client = processes.start_ssh_client(
'user:ssh@localhost',
'-p',
str(wg_ports['ssh']),
'-i',
'/dev/null',
'-o',
'PreferredAuthentications=password',
'ls',
'/bin/sh',
password='321',
)
ssh_client.communicate()
assert ssh_client.returncode != 0

View file

@ -0,0 +1,121 @@
from pathlib import Path
from textwrap import dedent
from .conftest import ProcessManager
from .util import wait_port
class Test:
def test_ed25519(
self, processes: ProcessManager, wg_c_ed25519_pubkey: Path
):
ssh_port = processes.start_ssh_server(
trusted_keys=[wg_c_ed25519_pubkey.read_text()]
)
_, wg_ports = processes.start_wg(
dedent(
f'''\
targets:
- name: ssh
allow_roles: [role]
ssh:
host: localhost
port: {ssh_port}
users:
- username: user
roles: [role]
credentials:
- type: publickey
key: {open('ssh-keys/id_ed25519.pub').read().strip()}
'''
),
)
wait_port(ssh_port)
wait_port(wg_ports['ssh'])
ssh_client = processes.start_ssh_client(
'user:ssh@localhost',
'-p',
str(wg_ports['ssh']),
'-o',
'IdentityFile=ssh-keys/id_ed25519',
'-o',
'PreferredAuthentications=publickey',
'ls',
'/bin/sh',
)
assert ssh_client.communicate()[0] == b'/bin/sh\n'
assert ssh_client.returncode == 0
ssh_client = processes.start_ssh_client(
'user:ssh@localhost',
'-p',
str(wg_ports['ssh']),
'-o',
'IdentityFile=ssh-keys/id_rsa',
'-o',
'PreferredAuthentications=publickey',
'ls',
'/bin/sh',
)
assert ssh_client.communicate()[0] == b''
assert ssh_client.returncode != 0
def test_rsa(
self, processes: ProcessManager, wg_c_ed25519_pubkey: Path
):
ssh_port = processes.start_ssh_server(
trusted_keys=[wg_c_ed25519_pubkey.read_text()]
)
_, wg_ports = processes.start_wg(
dedent(
f'''\
targets:
- name: ssh
allow_roles: [role]
ssh:
host: localhost
port: {ssh_port}
users:
- username: user
roles: [role]
credentials:
- type: publickey
key: {open('ssh-keys/id_rsa.pub').read().strip()}
'''
),
)
wait_port(ssh_port)
wait_port(wg_ports['ssh'])
ssh_client = processes.start_ssh_client(
'user:ssh@localhost',
'-p',
str(wg_ports['ssh']),
'-o',
'IdentityFile=ssh-keys/id_rsa',
'-o',
'PreferredAuthentications=publickey',
'ls',
'/bin/sh',
)
assert ssh_client.communicate()[0] == b'/bin/sh\n'
assert ssh_client.returncode == 0
ssh_client = processes.start_ssh_client(
'user:ssh@localhost',
'-p',
str(wg_ports['ssh']),
'-o',
'IdentityFile=ssh-keys/id_ed25519',
'-o',
'PreferredAuthentications=publickey',
'ls',
'/bin/sh',
)
assert ssh_client.communicate()[0] == b''
assert ssh_client.returncode != 0

View file

@ -0,0 +1,63 @@
from pathlib import Path
from textwrap import dedent
from .conftest import ProcessManager
from .util import create_ticket, wait_port
class Test:
def test(
self, processes: ProcessManager, wg_c_ed25519_pubkey: Path, password_123_hash
):
ssh_port = processes.start_ssh_server(
trusted_keys=[wg_c_ed25519_pubkey.read_text()]
)
_, wg_ports = processes.start_wg(
dedent(
f'''\
targets:
- name: ssh
allow_roles: [role]
ssh:
host: localhost
port: {ssh_port}
- name: admin
allow_roles: [admin]
web_admin: {{}}
users:
- username: user
roles: [role]
credentials:
- type: password
hash: '{password_123_hash}'
- username: admin
roles: [admin]
credentials:
- type: password
hash: '{password_123_hash}'
'''
),
)
wait_port(ssh_port)
wait_port(wg_ports['ssh'])
wait_port(wg_ports['http'], recv=False)
url = f'https://localhost:{wg_ports["http"]}'
secret = create_ticket(url, 'user', 'ssh')
ssh_client = processes.start_ssh_client(
f'ticket-{secret}@localhost',
'-p',
str(wg_ports['ssh']),
'-i',
'/dev/null',
'-o',
'PreferredAuthentications=password',
'ls',
'/bin/sh',
password='123',
)
assert ssh_client.communicate()[0] == b'/bin/sh\n'
assert ssh_client.returncode == 0

92
tests/util.py Normal file
View file

@ -0,0 +1,92 @@
import logging
import os
import requests
import socket
import subprocess
import threading
import time
last_port = 1234
mysql_client_ssl_opt = '--ssl'
if 'GITHUB_ACTION' in os.environ:
# Github uses MySQL instead of MariaDB
mysql_client_ssl_opt = '--ssl-mode=REQUIRED'
def alloc_port():
global last_port
last_port += 1
return last_port
def wait_port(port, recv=True):
logging.debug(f'Waiting for port {port}')
data = b''
def wait():
nonlocal data
while True:
try:
s = socket.create_connection(('localhost', port), timeout=5)
if recv:
data = s.recv(100)
else:
data = b''
s.close()
logging.debug(f'Port {port} is up')
return data
except socket.error:
time.sleep(0.1)
continue
t = threading.Thread(target=wait, daemon=True)
t.start()
t.join(timeout=5)
if t.is_alive():
raise Exception(f'Port {port} is not up')
return data
def wait_mysql_port(port):
logging.debug(f'Waiting for MySQL port {port}')
def wait():
while True:
try:
subprocess.check_call(f'mysql --user=root --password=123 --host=127.0.0.1 --port={port} --execute="show schemas;"', shell=True)
logging.debug(f'Port {port} is up')
break
except subprocess.CalledProcessError:
time.sleep(1)
continue
t = threading.Thread(target=wait, daemon=True)
t.start()
t.join(timeout=60)
if t.is_alive():
raise Exception(f'Port {port} is not up')
def create_ticket(url, username, target_name):
session = requests.Session()
session.verify = False
response = session.post(
f'{url}/@warpgate/api/auth/login',
json={
'username': 'admin',
'password': '123',
},
)
assert response.status_code // 100 == 2
response = session.post(
f'{url}/@warpgate/admin/api/tickets',
json={
'username': username,
'target_name': target_name,
},
)
assert response.status_code == 201
return response.json()['secret']

View file

@ -18,7 +18,7 @@ use russh_keys::PublicKeyBase64;
pub use server::run_server;
use uuid::Uuid;
use warpgate_common::{
ProtocolName, ProtocolServer, Services, Target, TargetOptions, TargetTestError,
ProtocolName, ProtocolServer, Services, Target, TargetOptions, TargetTestError, SshHostKeyVerificationMode,
};
use crate::client::{RCCommand, RemoteClient};
@ -81,13 +81,24 @@ impl ProtocolServer for SSHProtocolServer {
RCEvent::HostKeyUnknown(key, reply) => {
println!("\nHost key ({}): {}", key.name(), key.public_key_base64());
println!("There is no trusted {} key for this host.", key.name());
if dialoguer::Confirm::new()
.with_prompt("Trust this key?")
.interact()?
{
let _ = reply.send(true);
} else {
let _ = reply.send(false);
match self.services.config.lock().await.store.ssh.host_key_verification {
SshHostKeyVerificationMode::AutoAccept => {
let _ = reply.send(true);
}
SshHostKeyVerificationMode::AutoReject => {
let _ = reply.send(false);
}
SshHostKeyVerificationMode::Prompt => {
if dialoguer::Confirm::new()
.with_prompt("Trust this key?")
.interact()?
{
let _ = reply.send(true);
} else {
let _ = reply.send(false);
}
}
}
}
RCEvent::State(state) => match state {

View file

@ -936,9 +936,9 @@ impl ServerSession {
let selector: AuthSelector = (&ssh_username).into();
info!(
"Public key auth as {:?} with key FP {}",
"Public key auth as {:?} with key {}",
selector,
key.fingerprint()
key.public_key_base64()
);
match self
@ -1065,7 +1065,7 @@ impl ServerSession {
login_url.set_path("@warpgate");
login_url
.set_fragment(Some(&format!("/login?next=%2Flogin%2F{auth_state_id}")));
.set_fragment(Some(&format!("/login/{auth_state_id}")));
russh::server::Auth::Partial {
name: Cow::Owned(format!(

View file

@ -49,8 +49,6 @@ pub enum SsoInternalProviderConfig {
},
#[serde(rename = "custom")]
Custom {
name: String,
label: String,
client_id: ClientId,
client_secret: ClientSecret,
issuer_url: IssuerUrl,

View file

@ -13,8 +13,8 @@
"postinstall": "yarn run openapi:client:gateway && yarn run openapi:client:admin",
"openapi:schema:gateway": "cargo run -p warpgate-protocol-http > src/gateway/lib/openapi-schema.json",
"openapi:schema:admin": "cargo run -p warpgate-admin > src/admin/lib/openapi-schema.json",
"openapi:client:gateway": "openapi-generator-cli generate -g typescript-fetch -i src/gateway/lib/openapi-schema.json -o src/gateway/lib/api-client -p npmName=warpgate-gateway-api-client -p useSingleRequestParameter=true && cd src/gateway/lib/api-client && npm i typescript@3.5 && npm i && yarn tsc --target esnext --module esnext && rm -rf src",
"openapi:client:admin": "openapi-generator-cli generate -g typescript-fetch -i src/admin/lib/openapi-schema.json -o src/admin/lib/api-client -p npmName=warpgate-admin-api-client -p useSingleRequestParameter=true && cd src/admin/lib/api-client && npm i typescript@3.5 && npm i && yarn tsc --target esnext --module esnext && rm -rf src",
"openapi:client:gateway": "openapi-generator-cli generate -g typescript-fetch -i src/gateway/lib/openapi-schema.json -o src/gateway/lib/api-client -p npmName=warpgate-gateway-api-client -p useSingleRequestParameter=true && cd src/gateway/lib/api-client && npm i typescript@3.5 && npm i && yarn tsc --target esnext --module esnext && rm -rf src tsconfig.json",
"openapi:client:admin": "openapi-generator-cli generate -g typescript-fetch -i src/admin/lib/openapi-schema.json -o src/admin/lib/api-client -p npmName=warpgate-admin-api-client -p useSingleRequestParameter=true && cd src/admin/lib/api-client && npm i typescript@3.5 && npm i && yarn tsc --target esnext --module esnext && rm -rf src tsconfig.json",
"openapi": "yarn run openapi:schema:admin && yarn run openapi:schema:gateway && yarn run openapi:client:admin && yarn run openapi:client:gateway"
},
"devDependencies": {

View file

@ -183,7 +183,7 @@ async function startSSO (provider: SsoProviderDescription) {
click={login}
>
Login
<Fa class="ms-2" icon={faArrowRight} />
<Fa class="ms-2" fw icon={faArrowRight} />
</AsyncButton>
{#if authState === ApiAuthState.Failed}

View file

@ -4,6 +4,7 @@ use anyhow::Result;
use futures::StreamExt;
#[cfg(target_os = "linux")]
use sd_notify::NotifyState;
use tokio::signal::unix::SignalKind;
use tracing::*;
use warpgate_common::db::cleanup_db;
use warpgate_common::logging::install_database_logger;
@ -117,11 +118,16 @@ pub(crate) async fn command(cli: &crate::Cli) -> Result<()> {
services.clone(),
));
let mut sigint = tokio::signal::unix::signal(SignalKind::interrupt())?;
loop {
tokio::select! {
_ = tokio::signal::ctrl_c() => {
std::process::exit(1);
}
_ = sigint.recv() => {
break
}
result = protocol_futures.next() => {
match result {
Some(Err(error)) => {

View file

@ -53,8 +53,9 @@ pub(crate) async fn command(cli: &crate::Cli, target_name: &String) -> Result<()
}
Ok(()) => {
info!("Connection successful!");
return Ok(())
}
}
Ok(())
anyhow::bail!("Connection test failed")
}