Repository: warp-tech/warpgate Branch: main Commit: 3d077a3726b8 Files: 510 Total size: 1.9 MB Directory structure: gitextract_u506bnuo/ ├── .all-contributorsrc ├── .bumpversion.cfg ├── .cargo/ │ └── config.toml ├── .dockerignore ├── .flake8 ├── .github/ │ ├── FUNDING.yml │ ├── dependabot.yml │ └── workflows/ │ ├── build.yml │ ├── check-schema-compatibility.yml │ ├── codeql.yml │ ├── dependency-review.yml │ ├── docker.yml │ ├── reprotest.yml │ ├── scorecard.yml │ └── test.yml ├── .gitignore ├── .well-known/ │ └── funding-manifest-urls ├── Cargo.toml ├── Cranky.toml ├── Cross.toml ├── LICENSE ├── README.md ├── SECURITY.md ├── clippy.toml ├── config-schema.json ├── deny.toml ├── docker/ │ ├── Dockerfile │ └── docker-compose.yml ├── helm/ │ └── warpgate/ │ ├── .helmignore │ ├── Chart.yaml │ ├── templates/ │ │ ├── NOTES.txt │ │ ├── _helpers.tpl │ │ ├── configmap.yaml │ │ ├── deployment.yaml │ │ ├── httproute.yaml │ │ ├── ingress.yaml │ │ ├── pvc.yaml │ │ ├── service.yaml │ │ └── setup-job.yaml │ └── values.yaml ├── justfile ├── rust-toolchain ├── rustfmt.toml ├── sonar-project.properties ├── tests/ │ ├── .gitignore │ ├── Makefile │ ├── __init__.py │ ├── api_client.py │ ├── certs/ │ │ ├── tls.certificate.pem │ │ └── tls.key.pem │ ├── conftest.py │ ├── images/ │ │ ├── mysql-server/ │ │ │ ├── Dockerfile │ │ │ └── init.sql │ │ ├── postgres-server/ │ │ │ ├── Dockerfile │ │ │ └── init.sql │ │ └── ssh-server/ │ │ └── Dockerfile │ ├── oidc-mock/ │ │ ├── clients-config.json │ │ └── docker-compose.yml │ ├── pyproject.toml │ ├── run.sh │ ├── ssh-keys/ │ │ ├── id_ed25519 │ │ ├── id_ed25519.pub │ │ ├── id_rsa │ │ ├── id_rsa.pub │ │ └── wg/ │ │ ├── client-ed25519 │ │ ├── client-ed25519.pub │ │ ├── client-rsa │ │ ├── client-rsa.pub │ │ ├── host-ed25519 │ │ ├── host-ed25519.pub │ │ └── host-rsa │ ├── test_api_auth.py │ ├── test_http_basic.py │ ├── test_http_common.py │ ├── test_http_proto.py │ ├── test_http_redirects.py │ ├── test_http_user_auth_logout.py │ ├── test_http_user_auth_oidc.py │ ├── test_http_user_auth_otp.py │ ├── test_http_user_auth_password.py │ ├── test_http_user_auth_ticket.py │ ├── test_http_websocket.py │ ├── test_json_logs.py │ ├── test_kubernetes_integration.py │ ├── test_mysql_user_auth_password.py │ ├── test_postgres_user_auth_in_browser.py │ ├── test_postgres_user_auth_password.py │ ├── test_ssh_client_auth_config.py │ ├── test_ssh_proto.py │ ├── test_ssh_target_selection.py │ ├── test_ssh_user_auth_in_browser.py │ ├── test_ssh_user_auth_otp.py │ ├── test_ssh_user_auth_password.py │ ├── test_ssh_user_auth_pubkey.py │ ├── test_ssh_user_auth_ticket.py │ └── util.py ├── warpgate/ │ ├── .gitignore │ ├── Cargo.toml │ └── src/ │ ├── commands/ │ │ ├── check.rs │ │ ├── client_keys.rs │ │ ├── common.rs │ │ ├── create_user.rs │ │ ├── healthcheck.rs │ │ ├── mod.rs │ │ ├── recover_access.rs │ │ ├── run.rs │ │ └── setup.rs │ ├── config.rs │ ├── logging.rs │ └── main.rs ├── warpgate-admin/ │ ├── Cargo.toml │ └── src/ │ ├── api/ │ │ ├── admin_roles.rs │ │ ├── certificate_credentials.rs │ │ ├── common.rs │ │ ├── known_hosts_detail.rs │ │ ├── known_hosts_list.rs │ │ ├── ldap_servers.rs │ │ ├── logs.rs │ │ ├── mod.rs │ │ ├── otp_credentials.rs │ │ ├── pagination.rs │ │ ├── parameters.rs │ │ ├── password_credentials.rs │ │ ├── public_key_credentials.rs │ │ ├── recordings_detail.rs │ │ ├── roles.rs │ │ ├── sessions_detail.rs │ │ ├── sessions_list.rs │ │ ├── ssh_connection_test.rs │ │ ├── ssh_keys.rs │ │ ├── sso_credentials.rs │ │ ├── target_groups.rs │ │ ├── targets.rs │ │ ├── tickets_detail.rs │ │ ├── tickets_list.rs │ │ └── users.rs │ ├── lib.rs │ └── main.rs ├── warpgate-ca/ │ ├── Cargo.toml │ └── src/ │ ├── error.rs │ └── lib.rs ├── warpgate-common/ │ ├── Cargo.toml │ └── src/ │ ├── api.rs │ ├── auth/ │ │ ├── cred.rs │ │ ├── mod.rs │ │ ├── policy.rs │ │ ├── selector.rs │ │ └── state.rs │ ├── config/ │ │ ├── defaults.rs │ │ ├── mod.rs │ │ └── target.rs │ ├── config_schema.rs │ ├── consts.rs │ ├── error.rs │ ├── eventhub.rs │ ├── helpers/ │ │ ├── fs.rs │ │ ├── hash.rs │ │ ├── locks.rs │ │ ├── mod.rs │ │ ├── otp.rs │ │ ├── rng.rs │ │ ├── serde_base64.rs │ │ ├── serde_base64_secret.rs │ │ └── websocket.rs │ ├── http_headers.rs │ ├── lib.rs │ ├── state.rs │ ├── try_macro.rs │ ├── types/ │ │ ├── aliases.rs │ │ ├── listen_endpoint.rs │ │ ├── mod.rs │ │ └── secret.rs │ └── version.rs ├── warpgate-common-http/ │ ├── Cargo.toml │ └── src/ │ ├── auth.rs │ ├── lib.rs │ └── logging.rs ├── warpgate-core/ │ ├── Cargo.toml │ └── src/ │ ├── auth_state_store.rs │ ├── config_providers/ │ │ ├── db.rs │ │ └── mod.rs │ ├── consts.rs │ ├── data.rs │ ├── db/ │ │ └── mod.rs │ ├── lib.rs │ ├── logging/ │ │ ├── database.rs │ │ ├── json_console.rs │ │ ├── layer.rs │ │ ├── mod.rs │ │ ├── socket.rs │ │ └── values.rs │ ├── protocols/ │ │ ├── handle.rs │ │ └── mod.rs │ ├── rate_limiting/ │ │ ├── limiter.rs │ │ ├── mod.rs │ │ ├── registry.rs │ │ ├── shared_limiter.rs │ │ ├── stack.rs │ │ ├── stream.rs │ │ └── swappable_cell.rs │ ├── recordings/ │ │ ├── mod.rs │ │ ├── terminal.rs │ │ ├── traffic.rs │ │ └── writer.rs │ ├── services.rs │ └── state.rs ├── warpgate-database-protocols/ │ ├── Cargo.toml │ ├── README.md │ └── src/ │ ├── error.rs │ ├── io/ │ │ ├── buf.rs │ │ ├── buf_mut.rs │ │ ├── buf_stream.rs │ │ ├── decode.rs │ │ ├── encode.rs │ │ ├── mod.rs │ │ └── write_and_flush.rs │ ├── lib.rs │ └── mysql/ │ ├── collation.rs │ ├── io/ │ │ ├── buf.rs │ │ ├── buf_mut.rs │ │ └── mod.rs │ ├── mod.rs │ └── protocol/ │ ├── auth.rs │ ├── capabilities.rs │ ├── connect/ │ │ ├── auth_switch.rs │ │ ├── handshake.rs │ │ ├── handshake_response.rs │ │ ├── mod.rs │ │ └── ssl_request.rs │ ├── mod.rs │ ├── packet.rs │ ├── response/ │ │ ├── eof.rs │ │ ├── err.rs │ │ ├── mod.rs │ │ ├── ok.rs │ │ └── status.rs │ ├── row.rs │ └── text/ │ ├── column.rs │ ├── mod.rs │ ├── ping.rs │ ├── query.rs │ └── quit.rs ├── warpgate-db-entities/ │ ├── Cargo.toml │ └── src/ │ ├── AdminRole.rs │ ├── ApiToken.rs │ ├── CertificateCredential.rs │ ├── CertificateRevocation.rs │ ├── KnownHost.rs │ ├── LdapServer.rs │ ├── LogEntry.rs │ ├── OtpCredential.rs │ ├── Parameters.rs │ ├── PasswordCredential.rs │ ├── PublicKeyCredential.rs │ ├── Recording.rs │ ├── Role.rs │ ├── Session.rs │ ├── SsoCredential.rs │ ├── Target.rs │ ├── TargetGroup.rs │ ├── TargetRoleAssignment.rs │ ├── Ticket.rs │ ├── User.rs │ ├── UserAdminRoleAssignment.rs │ ├── UserRoleAssignment.rs │ └── lib.rs ├── warpgate-db-migrations/ │ ├── Cargo.toml │ ├── README.md │ └── src/ │ ├── lib.rs │ ├── m00001_create_ticket.rs │ ├── m00002_create_session.rs │ ├── m00003_create_recording.rs │ ├── m00004_create_known_host.rs │ ├── m00005_create_log_entry.rs │ ├── m00006_add_session_protocol.rs │ ├── m00007_targets_and_roles.rs │ ├── m00008_users.rs │ ├── m00009_credential_models.rs │ ├── m00010_parameters.rs │ ├── m00011_rsa_key_algos.rs │ ├── m00012_add_openssh_public_key_label.rs │ ├── m00013_add_openssh_public_key_dates.rs │ ├── m00014_api_tokens.rs │ ├── m00015_fix_public_key_dates.rs │ ├── m00016_fix_public_key_length.rs │ ├── m00017_descriptions.rs │ ├── m00018_ticket_description.rs │ ├── m00019_rate_limits.rs │ ├── m00020_target_groups.rs │ ├── m00021_ldap_server.rs │ ├── m00022_user_ldap_link.rs │ ├── m00023_ldap_username_attribute.rs │ ├── m00024_ssh_key_attribute.rs │ ├── m00025_ldap_uuid_attribute.rs │ ├── m00026_ssh_client_auth.rs │ ├── m00027_ca.rs │ ├── m00028_certificate_credentials.rs │ ├── m00029_certificate_revocation.rs │ ├── m00030_add_recording_metadata.rs │ ├── m00031_minimize_password_login.rs │ ├── m00032_admin_roles.rs │ └── main.rs ├── warpgate-ldap/ │ ├── Cargo.toml │ └── src/ │ ├── connection.rs │ ├── error.rs │ ├── lib.rs │ ├── queries.rs │ └── types.rs ├── warpgate-protocol-http/ │ ├── Cargo.toml │ └── src/ │ ├── api/ │ │ ├── api_tokens.rs │ │ ├── auth.rs │ │ ├── common.rs │ │ ├── credentials.rs │ │ ├── info.rs │ │ ├── mod.rs │ │ ├── sso_provider_detail.rs │ │ ├── sso_provider_list.rs │ │ └── targets_list.rs │ ├── catchall.rs │ ├── common.rs │ ├── error.rs │ ├── lib.rs │ ├── main.rs │ ├── middleware/ │ │ ├── cookie_host.rs │ │ ├── mod.rs │ │ └── ticket.rs │ ├── proxy.rs │ ├── session.rs │ └── session_handle.rs ├── warpgate-protocol-kubernetes/ │ ├── Cargo.toml │ └── src/ │ ├── correlator.rs │ ├── lib.rs │ ├── recording.rs │ ├── server/ │ │ ├── auth.rs │ │ ├── client_certs.rs │ │ ├── handlers.rs │ │ └── mod.rs │ └── session_handle.rs ├── warpgate-protocol-mysql/ │ ├── Cargo.toml │ └── src/ │ ├── client.rs │ ├── common.rs │ ├── error.rs │ ├── lib.rs │ ├── session.rs │ ├── session_handle.rs │ └── stream.rs ├── warpgate-protocol-postgres/ │ ├── Cargo.toml │ └── src/ │ ├── client.rs │ ├── common.rs │ ├── error.rs │ ├── lib.rs │ ├── session.rs │ ├── session_handle.rs │ └── stream.rs ├── warpgate-protocol-ssh/ │ ├── Cargo.toml │ └── src/ │ ├── client/ │ │ ├── channel_direct_tcpip.rs │ │ ├── channel_session.rs │ │ ├── error.rs │ │ ├── handler.rs │ │ └── mod.rs │ ├── common.rs │ ├── compat.rs │ ├── keys.rs │ ├── known_hosts.rs │ ├── lib.rs │ └── server/ │ ├── channel_writer.rs │ ├── mod.rs │ ├── russh_handler.rs │ ├── service_output.rs │ ├── session.rs │ └── session_handle.rs ├── warpgate-sso/ │ ├── Cargo.toml │ └── src/ │ ├── config.rs │ ├── error.rs │ ├── google_groups.rs │ ├── lib.rs │ ├── request.rs │ ├── response.rs │ └── sso.rs ├── warpgate-tls/ │ ├── Cargo.toml │ └── src/ │ ├── cert.rs │ ├── error.rs │ ├── lib.rs │ ├── maybe_tls_stream.rs │ ├── mode.rs │ ├── rustls_helpers.rs │ └── rustls_root_certs.rs └── warpgate-web/ ├── .editorconfig ├── .gitignore ├── Cargo.toml ├── eslint.config.mjs ├── openapitools.json ├── package.json ├── src/ │ ├── admin/ │ │ ├── App.svelte │ │ ├── AuthPolicyEditor.svelte │ │ ├── CertificateCredentialModal.svelte │ │ ├── CreateOtpModal.svelte │ │ ├── CreatePasswordModal.svelte │ │ ├── CredentialEditor.svelte │ │ ├── Home.svelte │ │ ├── KubernetesRecording.svelte │ │ ├── Log.svelte │ │ ├── LogViewer.svelte │ │ ├── PublicKeyCredentialModal.svelte │ │ ├── Recording.svelte │ │ ├── RelativeDate.svelte │ │ ├── Session.svelte │ │ ├── SsoCredentialModal.svelte │ │ ├── TlsConfiguration.svelte │ │ ├── config/ │ │ │ ├── AccessRole.svelte │ │ │ ├── AccessRoles.svelte │ │ │ ├── AdminRole.svelte │ │ │ ├── AdminRolePermissionsBadge.svelte │ │ │ ├── AdminRoles.svelte │ │ │ ├── Config.svelte │ │ │ ├── CreateAdminRole.svelte │ │ │ ├── CreateRole.svelte │ │ │ ├── CreateTicket.svelte │ │ │ ├── CreateUser.svelte │ │ │ ├── Parameters.svelte │ │ │ ├── SSHKeys.svelte │ │ │ ├── Tickets.svelte │ │ │ ├── User.svelte │ │ │ ├── Users.svelte │ │ │ ├── ldap/ │ │ │ │ ├── CreateLdapServer.svelte │ │ │ │ ├── LdapConnectionFields.svelte │ │ │ │ ├── LdapServer.svelte │ │ │ │ ├── LdapServers.svelte │ │ │ │ ├── LdapUserBrowser.svelte │ │ │ │ └── common.ts │ │ │ ├── target-groups/ │ │ │ │ ├── CreateTargetGroup.svelte │ │ │ │ ├── TargetGroup.svelte │ │ │ │ ├── TargetGroups.svelte │ │ │ │ └── common.ts │ │ │ └── targets/ │ │ │ ├── ChooseTargetKind.svelte │ │ │ ├── CreateTarget.svelte │ │ │ ├── Target.svelte │ │ │ ├── Targets.svelte │ │ │ └── ssh/ │ │ │ ├── KeyChecker.svelte │ │ │ ├── KeyCheckerResult.svelte │ │ │ └── Options.svelte │ │ ├── index.html │ │ ├── index.ts │ │ ├── lib/ │ │ │ ├── PermissionGate.svelte │ │ │ ├── api.ts │ │ │ ├── openapi-schema.json │ │ │ ├── store.ts │ │ │ └── time.ts │ │ └── player/ │ │ └── TerminalRecordingPlayer.svelte │ ├── common/ │ │ ├── AsyncButton.svelte │ │ ├── AuthBar.svelte │ │ ├── Brand.svelte │ │ ├── ConnectionInstructions.svelte │ │ ├── CopyButton.svelte │ │ ├── CredentialUsedStateBadge.svelte │ │ ├── DelayedSpinner.svelte │ │ ├── EmptyState.svelte │ │ ├── GettingStarted.svelte │ │ ├── GroupColorCircle.svelte │ │ ├── InfoBox.svelte │ │ ├── ItemList.svelte │ │ ├── Loadable.svelte │ │ ├── NavListItem.svelte │ │ ├── Pagination.svelte │ │ ├── RadioButton.svelte │ │ ├── RateLimitInput.svelte │ │ ├── ThemeSwitcher.svelte │ │ ├── autosave.ts │ │ ├── errors.ts │ │ ├── helpers.ts │ │ ├── protocols.ts │ │ ├── recordings.ts │ │ └── sveltestrap-s5-ports/ │ │ ├── Alert.svelte │ │ ├── Badge.svelte │ │ ├── ModalHeader.svelte │ │ ├── Tooltip.svelte │ │ └── _sveltestrapUtils.ts │ ├── embed/ │ │ ├── EmbeddedUI.svelte │ │ └── index.ts │ ├── gateway/ │ │ ├── ApiTokenManager.svelte │ │ ├── App.svelte │ │ ├── CreateApiTokenModal.svelte │ │ ├── CredentialManager.svelte │ │ ├── Login.svelte │ │ ├── OutOfBandAuth.svelte │ │ ├── Profile.svelte │ │ ├── ProfileApiTokens.svelte │ │ ├── ProfileCredentials.svelte │ │ ├── TargetList.svelte │ │ ├── index.html │ │ ├── index.ts │ │ ├── lib/ │ │ │ ├── api.ts │ │ │ ├── openapi-schema.json │ │ │ ├── shellEscape.ts │ │ │ └── store.ts │ │ └── login.ts │ ├── lib.rs │ ├── theme/ │ │ ├── _theme.scss │ │ ├── fonts.css │ │ ├── index.ts │ │ ├── theme.dark.scss │ │ ├── theme.light.scss │ │ ├── vars.common.scss │ │ ├── vars.dark.scss │ │ └── vars.light.scss │ └── vite-env.d.ts ├── svelte.config.js ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .all-contributorsrc ================================================ { "projectName": "warpgate", "projectOwner": "warp-tech", "repoType": "github", "repoHost": "https://github.com", "files": [ "README.md" ], "imageSize": 100, "commit": true, "commitConvention": "none", "contributors": [ { "login": "Eugeny", "name": "Eugeny", "avatar_url": "https://avatars.githubusercontent.com/u/161476?v=4", "profile": "https://github.com/Eugeny", "contributions": [ "code" ] }, { "login": "heywoodlh", "name": "Spencer Heywood", "avatar_url": "https://avatars.githubusercontent.com/u/18178614?v=4", "profile": "https://the-empire.systems/", "contributions": [ "code" ] }, { "login": "apiening", "name": "Andreas Piening", "avatar_url": "https://avatars.githubusercontent.com/u/2064875?v=4", "profile": "https://github.com/apiening", "contributions": [ "code" ] }, { "login": "Gurkengewuerz", "name": "Niklas", "avatar_url": "https://avatars.githubusercontent.com/u/10966337?v=4", "profile": "https://github.com/Gurkengewuerz", "contributions": [ "code" ] }, { "login": "notnooblord", "name": "Nooblord", "avatar_url": "https://avatars.githubusercontent.com/u/11678665?v=4", "profile": "https://github.com/notnooblord", "contributions": [ "code" ] }, { "login": "SheaSmith", "name": "Shea Smith", "avatar_url": "https://avatars.githubusercontent.com/u/51303984?v=4", "profile": "https://shea.nz/", "contributions": [ "code" ] }, { "login": "samtoxie", "name": "samtoxie", "avatar_url": "https://avatars.githubusercontent.com/u/7732658?v=4", "profile": "https://github.com/samtoxie", "contributions": [ "code" ] }, { "login": "pfoundation", "name": "P Foundation", "avatar_url": "https://avatars.githubusercontent.com/u/80860929?v=4", "profile": "https://p.foundation/", "contributions": [ "financial" ] }, { "login": "alairock", "name": "Skyler Lewis", "avatar_url": "https://avatars.githubusercontent.com/u/1480236?v=4", "profile": "http://sixteenink.com", "contributions": [ "code" ] }, { "login": "MohammedNoureldin", "name": "Mohammed Noureldin", "avatar_url": "https://avatars.githubusercontent.com/u/14913147?v=4", "profile": "http://www.mohammednoureldin.com", "contributions": [ "code" ] }, { "login": "mrmm", "name": "Mourad Maatoug", "avatar_url": "https://avatars.githubusercontent.com/u/796467?v=4", "profile": "https://github.com/mrmm", "contributions": [ "code" ] }, { "login": "justinforlenza", "name": "Justin", "avatar_url": "https://avatars.githubusercontent.com/u/11709872?v=4", "profile": "http://justinforlenza.dev", "contributions": [ "code" ] }, { "login": "liebermantodd", "name": "liebermantodd", "avatar_url": "https://avatars.githubusercontent.com/u/12155811?v=4", "profile": "https://github.com/liebermantodd", "contributions": [ "code" ] }, { "login": "cvhariharan", "name": "Hariharan", "avatar_url": "https://avatars.githubusercontent.com/u/14965074?v=4", "profile": "https://blog.trieoflogs.com", "contributions": [ "code" ] }, { "login": "solidassassin", "name": "Rokas Krivaitis", "avatar_url": "https://avatars.githubusercontent.com/u/47082246?v=4", "profile": "https://github.com/solidassassin", "contributions": [ "code" ] } ], "contributorsPerLine": 7, "commitType": "docs" } ================================================ FILE: .bumpversion.cfg ================================================ [bumpversion] current_version = 0.22.0-beta.2 commit = True tag = True [bumpversion:file:warpgate/Cargo.toml] search = version = "{current_version}" replace = version = "{new_version}" [bumpversion:file:warpgate-admin/Cargo.toml] search = version = "{current_version}" replace = version = "{new_version}" [bumpversion:file:warpgate-ca/Cargo.toml] search = version = "{current_version}" replace = version = "{new_version}" [bumpversion:file:warpgate-common/Cargo.toml] search = version = "{current_version}" replace = version = "{new_version}" [bumpversion:file:warpgate-common-http/Cargo.toml] search = version = "{current_version}" replace = version = "{new_version}" [bumpversion:file:warpgate-core/Cargo.toml] search = version = "{current_version}" replace = version = "{new_version}" [bumpversion:file:warpgate-database-protocols/Cargo.toml] search = version = "{current_version}" replace = version = "{new_version}" [bumpversion:file:warpgate-db-entities/Cargo.toml] search = version = "{current_version}" replace = version = "{new_version}" [bumpversion:file:warpgate-db-migrations/Cargo.toml] search = version = "{current_version}" replace = version = "{new_version}" [bumpversion:file:warpgate-protocol-kubernetes/Cargo.toml] search = version = "{current_version}" replace = version = "{new_version}" [bumpversion:file:warpgate-ldap/Cargo.toml] search = version = "{current_version}" replace = version = "{new_version}" [bumpversion:file:warpgate-protocol-http/Cargo.toml] search = version = "{current_version}" replace = version = "{new_version}" [bumpversion:file:warpgate-protocol-mysql/Cargo.toml] search = version = "{current_version}" replace = version = "{new_version}" [bumpversion:file:warpgate-protocol-postgres/Cargo.toml] search = version = "{current_version}" replace = version = "{new_version}" [bumpversion:file:warpgate-protocol-ssh/Cargo.toml] search = version = "{current_version}" replace = version = "{new_version}" [bumpversion:file:warpgate-tls/Cargo.toml] search = version = "{current_version}" replace = version = "{new_version}" [bumpversion:file:warpgate-sso/Cargo.toml] search = version = "{current_version}" replace = version = "{new_version}" [bumpversion:file:warpgate-web/Cargo.toml] search = version = "{current_version}" replace = version = "{new_version}" ================================================ FILE: .cargo/config.toml ================================================ # https://github.com/rust-lang/cargo/issues/5376#issuecomment-2163350032 [target.'cfg(all())'] rustflags = [ "--cfg", "tokio_unstable", "-Zremap-cwd-prefix=/reproducible-cwd", "--remap-path-prefix=$HOME=/reproducible-home", "--remap-path-prefix=$PWD=/reproducible-pwd", ] ================================================ FILE: .dockerignore ================================================ data* cdx dhap-heap.json .pytest_cache # Generated by Cargo # will have compiled files and executables target */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 warpgate-web/dist warpgate-web/node_modules warpgate-web/src/admin/lib/api-client/ warpgate-web/src/gateway/lib/api-client/ ================================================ FILE: .flake8 ================================================ [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 ================================================ FILE: .github/FUNDING.yml ================================================ github: eugeny open_collective: tabby ko_fi: eugeny ================================================ FILE: .github/dependabot.yml ================================================ # Please see the documentation for all configuration options: # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: "cargo" directory: "/" labels: ["type/deps"] #open-pull-requests-limit: 25 schedule: interval: "daily" groups: version-bumps: applies-to: version-updates update-types: - minor - patch - package-ecosystem: "npm" directory: "/warpgate-web" labels: ["type/deps"] #open-pull-requests-limit: 25 groups: version-bumps: applies-to: version-updates update-types: - minor - patch schedule: interval: "daily" - package-ecosystem: github-actions directory: / schedule: interval: daily - package-ecosystem: docker directory: /docker schedule: interval: daily ================================================ FILE: .github/workflows/build.yml ================================================ name: Build permissions: contents: read on: [push, pull_request] jobs: build: strategy: matrix: include: - arch: x86_64-linux target: x86_64-unknown-linux-gnu os: ubuntu-22.04 # older image for glibc compatibility cyclonedx-build: cyclonedx-linux-x64 cargo-cross: false - arch: arm64-linux target: aarch64-unknown-linux-gnu os: ubuntu-22.04-arm # older image for glibc compatibility cyclonedx-build: cyclonedx-linux-arm64 cargo-cross: false - arch: x86_64-macos target: x86_64-apple-darwin os: macos-latest cyclonedx-build: cyclonedx-osx-x64 cargo-cross: false - arch: arm64-macos target: aarch64-apple-darwin os: macos-latest cyclonedx-build: cyclonedx-osx-arm64 cargo-cross: true fail-fast: false name: Build (${{ matrix.arch }}) runs-on: ${{ matrix.os }} permissions: contents: write id-token: write attestations: write artifact-metadata: write steps: - if: startsWith(matrix.os, 'ubuntu') run: | sudo apt update sudo apt install -y libssl-dev pkg-config - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: submodules: recursive - uses: rlespinasse/github-slug-action@9e7def61550737ba68c62d34a32dd31792e3f429 - uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af with: target: ${{ matrix.target }} override: true - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 with: key: "build" - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f with: node-version: 24 - name: Install tools run: | cargo install --locked just cargo install --locked cargo-deny@0.18.9 cargo install --locked cargo-cyclonedx@^0.5 rm -rf ~/.cargo/registry - name: Install CycloneDX run: | mkdir cdx wget https://github.com/CycloneDX/cyclonedx-cli/releases/download/v0.27.2/${{ matrix.cyclonedx-build }} -O cyclonedx chmod +x cyclonedx - name: Install CycloneDX-npm run: | cd / npm i -g @cyclonedx/cyclonedx-npm@4.1.2 - name: cargo-deny run: | cargo deny --version cargo deny check - name: Install admin UI deps run: | just npm ci - name: Build admin UI run: | just npm run build - name: Generate admin UI BOM run: | just npm prune cd warpgate-web && NODE_ENV=dev cyclonedx-npm --output-format xml > ../cdx/admin-ui.cdx.xml - name: Build uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 with: command: build use-cross: ${{ matrix.cargo-cross }} args: --all-features --release --target ${{ matrix.target }} env: ENV SOURCE_DATE_EPOCH: "0" # for rust-embed determinism CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: "--cfg tokio_unstable --remap-path-prefix=$HOME=/reproducible-home --remap-path-prefix=$PWD=/reproducible-pwd" CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS: "--cfg tokio_unstable --remap-path-prefix=$HOME=/reproducible-home --remap-path-prefix=$PWD=/reproducible-pwd" CARGO_TARGET_X86_64_APPLE_DARWIN_RUSTFLAGS: "--cfg tokio_unstable --remap-path-prefix=$HOME=/reproducible-home --remap-path-prefix=$PWD=/reproducible-pwd" CARGO_TARGET_AARCH64_APPLE_DARWIN_RUSTFLAGS: "--cfg tokio_unstable --remap-path-prefix=$HOME=/reproducible-home --remap-path-prefix=$PWD=/reproducible-pwd" - name: Generate Rust BOM run: | cargo cyclonedx --all-features mv warpgate*/*.cdx.xml cdx/ - name: Merge BOMs run: ./cyclonedx merge --input-files cdx/* --input-format xml --output-format xml > cdx.xml - name: Attest build uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 if: startsWith(github.ref, 'refs/tags/v') with: subject-path: target/${{ matrix.target }}/release/warpgate - name: Upload artifact uses: actions/upload-artifact@2848b2cda0e5190984587ec6bb1f36730ca78d50 with: name: warpgate-${{ env.GITHUB_REF_SLUG }}-${{ matrix.arch }} path: target/${{ matrix.target }}/release/warpgate - name: Upload SBOM uses: actions/upload-artifact@2848b2cda0e5190984587ec6bb1f36730ca78d50 with: name: warpgate-${{ env.GITHUB_REF_SLUG }}-${{ matrix.arch }}.cdx.xml path: cdx.xml - name: Rename artifacts run: | mkdir dist mv target/${{ matrix.target }}/release/warpgate dist/warpgate-${{ env.GITHUB_REF_SLUG }}-${{ matrix.arch }} mv cdx.xml dist/warpgate-${{ env.GITHUB_REF_SLUG }}-${{ matrix.arch }}.cdx.xml - name: Upload release uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe if: startsWith(github.ref, 'refs/tags/v') with: draft: true generate_release_notes: true files: dist/* token: ${{ secrets.GITHUB_TOKEN }} config-schema: name: Config schema check runs-on: ubuntu-24.04 steps: - name: Setup run: | sudo apt update sudo apt install --no-install-recommends -y libssl-dev pkg-config - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: submodules: recursive - name: Install tools run: | cargo install --locked just - name: Ensure there are no changes in config schema run: | mkdir warpgate-web/dist just config-schema git diff --exit-code config-schema.json ================================================ FILE: .github/workflows/check-schema-compatibility.yml ================================================ name: Check API Schema Compatibility on: [pull_request] permissions: contents: read jobs: check-schema-compatibility: runs-on: ubuntu-latest steps: - name: Checkout PR branch uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: fetch-depth: 0 - name: Install just run: | cargo install just - name: Install npm dependencies run: | cd warpgate-web && npm ci - name: Generate API schema files run: | mkdir warpgate-web/dist just openapi-all - name: Checkout main branch to compare uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: ref: main path: main-branch - name: Check admin API run: | docker run --rm -t -v $(pwd):/specs:ro tufin/oasdiff breaking -f githubactions --fail-on WARN /specs/main-branch/warpgate-web/src/admin/lib/openapi-schema.json /specs/warpgate-web/src/admin/lib/openapi-schema.json - name: Check gateway API run: | docker run --rm -t -v $(pwd):/specs:ro tufin/oasdiff breaking -f githubactions --fail-on WARN /specs/main-branch/warpgate-web/src/gateway/lib/openapi-schema.json /specs/warpgate-web/src/gateway/lib/openapi-schema.json ================================================ FILE: .github/workflows/codeql.yml ================================================ # For most projects, this workflow file will not need changing; you simply need # to commit it to your repository. # # You may wish to alter this file to override the set of languages analyzed, # or to provide custom queries or build logic. # # ******** NOTE ******** # We have attempted to detect the languages in your repository. Please check # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # name: "CodeQL Advanced" permissions: contents: read on: push: branches: [ "main" ] pull_request: branches: [ "main" ] schedule: - cron: '19 11 * * 0' jobs: analyze: name: Analyze (${{ matrix.language }}) # Runner size impacts CodeQL analysis time. To learn more, please see: # - https://gh.io/recommended-hardware-resources-for-running-codeql # - https://gh.io/supported-runners-and-hardware-resources # - https://gh.io/using-larger-runners (GitHub.com only) # Consider using larger runners or machines with greater resources for possible analysis time improvements. runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} permissions: # required for all workflows security-events: write # required to fetch internal or private CodeQL packs packages: read # only required for workflows in private repositories actions: read contents: read strategy: fail-fast: false matrix: include: - language: actions build-mode: none - language: javascript-typescript build-mode: none - language: rust build-mode: none steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # Add any setup steps before running the `github/codeql-action/init` action. # This includes steps like installing compilers or runtimes (`actions/setup-node` # or others). This is typically only required for manual builds. # - name: Setup runtime (example) # uses: actions/setup-example@v1 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@b1bff81932f5cdfc8695c7752dcee935dcd061c8 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs # queries: security-extended,security-and-quality - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@b1bff81932f5cdfc8695c7752dcee935dcd061c8 with: category: "/language:${{matrix.language}}" ================================================ FILE: .github/workflows/dependency-review.yml ================================================ # 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: 'Dependency Review' uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 ================================================ FILE: .github/workflows/docker.yml ================================================ name: Docker permissions: read-all on: schedule: - cron: '25 12 * * *' push: branches: [ '**' ] tags: [ 'v*.*.*' ] # Publish semver tags as releases. pull_request: branches: [ '**' ] env: REGISTRY: ghcr.io IMAGE_NAME: warp-tech/warpgate jobs: build: runs-on: ${{matrix.os}} strategy: matrix: include: - os: ubuntu-24.04 docker-platform: linux/amd64 matrix-id: amd64 - os: ubuntu-24.04-arm docker-platform: linux/arm64 matrix-id: arm64 permissions: contents: read packages: write id-token: write attestations: write steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: submodules: recursive fetch-depth: 0 - name: Set up QEMU uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd - name: Log into registry ${{ env.REGISTRY }} if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Docker meta id: meta uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - name: Build Docker image without pushing if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository id: build-no-push uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 with: file: docker/Dockerfile context: . push: false labels: ${{ steps.meta.outputs.labels }} platforms: ${{ matrix.docker-platform }} cache-from: type=gha,scope=build-${{ matrix.docker-platform }} - name: Build and push Docker image if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository id: build uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 with: file: docker/Dockerfile context: . push: true labels: ${{ steps.meta.outputs.labels }} platforms: ${{ matrix.docker-platform }} cache-from: type=gha,scope=build-${{ matrix.docker-platform }} cache-to: type=gha,scope=build-${{ matrix.docker-platform }} outputs: type=image,"name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}",push-by-digest=true,name-canonical=true,push=true # Provenance attestations generates an unknown/unknown platform layer # that causes issues, see #1255 attests: '' provenance: false - name: Export digest if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository run: | mkdir -p ${{ runner.temp }}/digests digest="${{ steps.build.outputs.digest }}" touch "${{ runner.temp }}/digests/${digest#sha256:}" - name: Upload digest if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: digests-${{ matrix.matrix-id }} path: ${{ runner.temp }}/digests/* if-no-files-found: error retention-days: 1 merge: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest needs: - build permissions: contents: read packages: write id-token: write attestations: write steps: - name: Download digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: path: ${{ runner.temp }}/digests pattern: digests-* merge-multiple: true - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd - name: Log into registry ${{ env.REGISTRY }} uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Docker meta id: meta uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=ref,event=branch,prefix=branch- type=ref,event=pr,prefix=pr- type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=schedule - name: Create manifest list and push working-directory: ${{ runner.temp }}/digests run: | docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ $(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *) - name: Inspect image run: | docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} ================================================ FILE: .github/workflows/reprotest.yml ================================================ name: Reproducibility test permissions: contents: read on: workflow_dispatch jobs: reprotest: name: Reproducibility test runs-on: ubuntu-24.04 steps: - name: Setup run: | sudo apt update sudo apt install --no-install-recommends -y libssl-dev pkg-config disorderfs faketime locales-all reprotest diffoscope test -c /dev/fuse || mknod -m 666 /dev/fuse c 10 229 test -f /etc/mtab || ln -s ../proc/self/mounts /etc/mtab curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sudo sh -s -- -y echo "/root/.cargo/bin" >> $GITHUB_PATH - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: submodules: recursive - name: Install tools run: | sudo env "PATH=$PATH" cargo install --locked just - name: Reprotest run: | sudo ulimit -n 999999 sudo env "PATH=$PATH" reprotest -vv --min-cpus=99999 --vary=environment,build_path,kernel,aslr,num_cpus,-time,-user_group,fileordering,domain_host,home,locales,exec_path,timezone,umask --build-command 'just npm ci; just npm run build; SOURCE_DATE_EPOCH=0 cargo build --all-features --release' . target/release/warpgate ================================================ FILE: .github/workflows/scorecard.yml ================================================ # This workflow uses actions that are not certified by GitHub. They are provided # by a third-party and are governed by separate terms of service, privacy # policy, and support documentation. name: Scorecard supply-chain security on: # For Branch-Protection check. Only the default branch is supported. See # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection branch_protection_rule: # To guarantee Maintained check is occasionally updated. See # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained schedule: - cron: '42 9 * * 6' push: branches: [ "main" ] # Declare default permissions as read only. permissions: read-all jobs: analysis: name: Scorecard analysis runs-on: ubuntu-latest # `publish_results: true` only works when run from the default branch. conditional can be removed if disabled. if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request' permissions: # Needed to upload the results to code-scanning dashboard. security-events: write # Needed to publish results and get a badge (see publish_results below). id-token: write # Uncomment the permissions below if installing in a private repository. # contents: read # actions: read steps: - name: "Checkout code" uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: "Run analysis" uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 with: results_file: results.sarif results_format: sarif # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: # - you want to enable the Branch-Protection check on a *public* repository, or # - you are installing Scorecard on a *private* repository # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional. # repo_token: ${{ secrets.SCORECARD_TOKEN }} # Public repositories: # - Publish results to OpenSSF REST API for easy access by consumers # - Allows the repository to include the Scorecard badge. # - See https://github.com/ossf/scorecard-action#publishing-results. # For private repositories: # - `publish_results` will always be set to `false`, regardless # of the value entered here. publish_results: true # (Optional) Uncomment file_mode if you have a .gitattributes with files marked export-ignore # file_mode: git # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - name: "Upload artifact" uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 with: name: SARIF file path: results.sarif retention-days: 5 # Upload the results to GitHub's code scanning dashboard (optional). # Commenting out will disable upload of results to your repo's Code Scanning dashboard - name: "Upload to code-scanning" uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0 with: sarif_file: results.sarif ================================================ FILE: .github/workflows/test.yml ================================================ name: Test on: [push, pull_request] permissions: contents: read jobs: Tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: submodules: recursive - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f with: node-version: 24 - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 with: key: "test" - name: Install build deps run: | sudo apt-get install openssh-client expect cargo install --locked just # Pin cargo-llvm-cov to 0.6.15 because versions 0.6.16+ depend on ruzstd 0.8.x which uses # the unstable `unsigned_is_multiple_of` feature not available in nightly-2025-01-01 cargo install --locked cargo-llvm-cov@0.6.15 cargo clean rustup component add llvm-tools-preview - name: Build UI run: | just npm ci just openapi just npm run openapi:tests-sdk just npm run build - name: Build images working-directory: tests run: | make all - name: Install deps working-directory: tests run: | sudo apt update sudo apt install -y gnome-keyring kubectl pip3 install keyring==24 poetry==1.8.3 poetry install - name: Run working-directory: tests run: | TIMEOUT=120 poetry run ./run.sh cargo llvm-cov report --lcov > coverage.lcov - name: Upload coverage uses: actions/upload-artifact@2848b2cda0e5190984587ec6bb1f36730ca78d50 with: name: coverage.lcov path: tests/coverage.lcov - name: Upload coverage (HTML) uses: actions/upload-artifact@2848b2cda0e5190984587ec6bb1f36730ca78d50 with: name: coverage-html path: target/llvm-cov/html - name: SonarCloud Scan uses: SonarSource/sonarqube-scan-action@a31c9398be7ace6bbfaf30c0bd5d415f843d45e9 if: ${{ env.SONAR_TOKEN }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} ================================================ FILE: .gitignore ================================================ # 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 data-* config.*.yaml config.yaml __pycache__ .pytest_cache dhat-heap.json # IntelliJ based IDEs .idea/ /.data/ cdx.xml *.cdx.xml ================================================ FILE: .well-known/funding-manifest-urls ================================================ https://null.page/funding.json ================================================ FILE: Cargo.toml ================================================ # cargo-features = ["profile-rustflags"] [workspace] members = [ "warpgate", "warpgate-admin", "warpgate-common", "warpgate-common-http", "warpgate-tls", "warpgate-core", "warpgate-db-migrations", "warpgate-db-entities", "warpgate-database-protocols", "warpgate-ldap", "warpgate-protocol-http", "warpgate-protocol-kubernetes", "warpgate-protocol-mysql", "warpgate-protocol-postgres", "warpgate-protocol-ssh", "warpgate-sso", "warpgate-web", ] default-members = ["warpgate"] resolver = "2" [workspace.dependencies] anyhow = { version = "1.0", default-features = false, features = ["std"] } bytes = { version = "1.4", default-features = false } data-encoding = { version = "2.3", default-features = false, features = ["alloc", "std"] } serde = { version = "1.0", features = ["derive"], default-features = false } serde_json = { version = "1.0", default-features = false } russh = { version = "0.58.0", features = ["des", "rsa", "aws-lc-rs"], default-features = false } futures = { version = "0.3", default-features = false } tokio-stream = { version = "0.1.17", features = ["net"], default-features = false } tokio-rustls = { version = "0.26", default-features = false } enum_dispatch = { version = "0.3.13", default-features = false } rustls = { version = "0.23", default-features = false, features = ["tls12"] } sqlx = { version = "0.8", features = ["tls-rustls-aws-lc-rs"], default-features = false } sea-orm = { version = "1.0", default-features = false, features = ["runtime-tokio", "macros"] } sea-orm-migration = { version = "1.0", default-features = false, features = [ "cli", ] } poem = { version = "3.1", features = [ "cookie", "session", "anyhow", "websocket", "rustls", "embed", "server", ], default-features = false } hex = { version = "0.4", default-features = false } poem-openapi = { version = "5.1", features = [ "stoplight-elements", "chrono", "uuid", "static-files", "cookie", ], default-features = false } password-hash = { version = "0.5", features = ["std"], default-features = false } delegate = { version = "0.13", default-features = false } tracing = { version = "0.1", default-features = false } schemars = { version = "0.9.0", default-features = false, features = ["derive", "std"] } ldap3 = { version = "0.12", default-features = false, features = ["tls-rustls-aws-lc-rs"] } rustls-pki-types = { version = "1.13", default-features = false, features = ["alloc", "std"] } thiserror = { version = "2", default-features = false } rand = { version = "0.8", default-features = false } rand_chacha = { version = "0.3", default-features = false } rand_core = { version = "0.6", features = ["std"], default-features = false } dialoguer = { version = "0.11", default-features = false, features = ["editor", "password"] } tokio = { version = "1.20", features = ["tracing", "signal", "macros", "rt-multi-thread", "io-util"], default-features = false } governor = { version = "0.10.0", default-features = false, features = ["std", "quanta", "jitter"] } rcgen = { version = "0.13", features = ["zeroize", "crypto", "aws_lc_rs", "pem", "x509-parser"], default-features = false } x509-parser = "0.17.0" uuid = { version = "1.3", features = ["v4", "serde"], default-features = false } reqwest = { version = "0.13", features = [ "http2", # required for connecting to targets behind AWS ELB "rustls-no-provider", "stream", "gzip", ], default-features = false } reqwest_12 = { package = "reqwest", version = "0.12", features = [ "http2", "rustls-tls-native-roots-no-provider", "stream", "gzip", ], default-features = false } # separate copy to control features on openidconnect->oauth2->reqwest regex = { version = "1.6", default-features = false, features = ["std"] } tokio-tungstenite = { version = "0.27", features = ["rustls-tls-native-roots", "connect"], default-features = false } reqwest-websocket = "0.6.0" [profile.release] lto = true panic = "abort" strip = "debuginfo" [profile.coverage] inherits = "dev" # rustflags = ["-Cinstrument-coverage"] ================================================ FILE: Cranky.toml ================================================ deny = [ "unsafe_code", "clippy::unwrap_used", "clippy::expect_used", "clippy::panic", "clippy::indexing_slicing", "clippy::dbg_macro", ] allow = [ "clippy::result_large_err", ] ================================================ FILE: Cross.toml ================================================ [target.x86_64-unknown-linux-gnu] pre-build = ["apt-get update && apt-get install --assume-yes libz-dev"] ================================================ FILE: LICENSE ================================================ 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 2022 Warpgate contributors 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. ================================================ FILE: README.md ================================================


Shows a black logo in light color mode and a white one in dark color mode.


GitHub All Releases     Discord

--- Warpgate is a smart & fully transparent SSH, HTTPS, Kubernetes, MySQL, PostgreSQL bastion host that doesn't require a client app or an SSH wrapper. * Set it up in your DMZ, add user accounts and easily assign them to specific hosts and URLs within the network. * Warpgate will record every session for you to view (live) and replay later through a built-in admin web UI. * Not a jump host - forwards connection straight to the target in a way that's fully transparent to the client. * Native 2FA and SSO support (TOTP & OpenID Connect) * Single binary with no dependencies. * Written in 100% safe Rust.

Supported by:

FLOSS/fund badge

## Getting started & downloads * See the [Getting started](https://warpgate.null.page/getting-started/) docs page (or [Getting started on Docker](https://warpgate.null.page/getting-started-on-docker/)). * [Release / beta binaries](https://github.com/warp-tech/warpgate/releases) * [Nightly builds](https://nightly.link/warp-tech/warpgate/workflows/build/main) ## How is Warpgate different from a jump host / VPN / Teleport? | Warpgate | SSH jump host | VPN | Teleport | |-|-|-|-| | ✅ **Precise 1:1 assignment between users and services** | (Usually) full access to the network behind the jump host | (Usually) full access to the network | ✅ **Precise 1:1 assignment between users and services** | | ✅ **No custom client needed** | Jump host config needed | ✅ **No custom client needed** | Custom client required | | ✅ **2FA out of the box** | 🟡 2FA possible with additional PAM plugins | 🟡 Depends on the provider | ✅ **2FA out of the box** | | ✅ **SSO out of the box** | 🟡 SSO possible with additional PAM plugins | 🟡 Depends on the provider | Paid | | ✅ **Command-level audit** | 🟡 Connection-level audit on the jump host, no secure audit on the target if root access is given | No secure audit on the target if root access is given | ✅ **Command-level audit** | | ✅ **Full session recording** | No secure recording possible on the target if root access is given | No secure recording possible on the target if root access is given | ✅ **Full session recording** | | ✅ **Non-interactive connections** | 🟡 Non-interactive connections are possible if the clients supports jump hosts natively | ✅ **Non-interactive connections** | Non-interactive connections require using an SSH client wrapper or running a tunnel | | ✅ **Self-hosted, you own the data** | ✅ **Self-hosted, you own the data** | 🟡 Depends on the provider | SaaS |
image
## Reporting security issues Please use GitHub's [vulnerability reporting system](https://github.com/warp-tech/warpgate/security/policy). ## Project Status The project is ready for production. ## How it works Warpgate is a service that you deploy on the bastion/DMZ host, which will accept SSH, HTTPS, Kubernetes, MySQL and PostgreSQL connections and provide an (optional) web admin UI. Run `warpgate setup` to interactively generate a config file, including port bindings. See [Getting started](https://warpgate.null.page/getting-started/) for details. It receives connections with specifically formatted credentials, authenticates the user locally, connects to the target itself, and then connects both parties together while (optionally) recording the session. When connecting through HTTPS, Warpgate presents a selection of available targets, and will then proxy all traffic in a session to the selected target. You can switch between targets at any time. You manage the target and user lists and assign them to each other through the admin UI, and the session history is stored in an SQLite database (default: in `/var/lib/warpgate`). You can also use the admin web interface to view the live session list, review session recordings, logs and more. ## Contributing / building from source * You'll need Rust, NodeJS and NPM * 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 npm install` * Build the frontend: `just npm run build` * Build Warpgate: `cargo build` (optionally `--release`) The binary is in `target/{debug|release}`. ### Tech stack * Rust 🦀 * HTTP: `poem-web` * Database: SQLite via `sea-orm` + `sqlx` * SSH: `russh` * Typescript * Svelte * Bootstrap ### Backend API * Warpgate admin and user facing APIs use autogenerated OpenAPI schemas and SDKs. To update the SDKs after changing the query/response structures, run `just openapi-all`. ## Contributors ✨ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
Eugeny
Eugeny

💻
Spencer Heywood
Spencer Heywood

💻
Andreas Piening
Andreas Piening

💻
Niklas
Niklas

💻
Nooblord
Nooblord

💻
Shea Smith
Shea Smith

💻
samtoxie
samtoxie

💻
P Foundation
P Foundation

💵
Skyler Lewis
Skyler Lewis

💻
Mohammed Noureldin
Mohammed Noureldin

💻
Mourad Maatoug
Mourad Maatoug

💻
Justin
Justin

💻
liebermantodd
liebermantodd

💻
Hariharan
Hariharan

💻
Rokas Krivaitis
Rokas Krivaitis

💻
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Reporting a Vulnerability Please report vunerabilities using GitHub's Private Vulnerability Reporting tool. You can expect a response within a few days. --- Warpgate considers the following trusted inputs: * Contents of the connected database * Contents of the config file, as long as Warpgate does not fail to lock down its permissions. * HTTP requests made by a session previously authenticated by a user who has the `warpgate:admin` role. * Network infrastructure and actuality and stability of target IPs/hostnames. In particular, this does not include the traffic from known Warpgate targets. --- CNA: [GitHub](https://www.cve.org/PartnerInformation/ListofPartners/partner/GitHub_M) ================================================ FILE: clippy.toml ================================================ avoid-breaking-exported-api = false allow-unwrap-in-tests = true ================================================ FILE: config-schema.json ================================================ { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "WarpgateConfigStore", "type": "object", "properties": { "database_url": { "type": "string", "default": "sqlite:data/db" }, "external_host": { "type": [ "string", "null" ], "default": null }, "http": { "$ref": "#/$defs/HttpConfig", "default": { "certificate": "", "cookie_max_age": "1day", "external_port": null, "key": "", "listen": "[::]:8888", "session_max_age": "30m", "sni_certificates": [], "trust_x_forwarded_headers": false } }, "kubernetes": { "$ref": "#/$defs/KubernetesConfig", "default": { "certificate": "", "enable": false, "external_port": null, "key": "", "listen": "[::]:8443", "session_max_age": "30m" } }, "log": { "$ref": "#/$defs/LogConfig", "default": { "format": "text", "retention": "7days", "send_to": null } }, "mysql": { "$ref": "#/$defs/MySqlConfig", "default": { "certificate": "", "enable": false, "external_port": null, "key": "", "listen": "[::]:33306" } }, "postgres": { "$ref": "#/$defs/PostgresConfig", "default": { "certificate": "", "enable": false, "external_port": null, "key": "", "listen": "[::]:55432" } }, "recordings": { "$ref": "#/$defs/RecordingsConfig", "default": { "enable": false, "path": "./data/recordings" } }, "ssh": { "$ref": "#/$defs/SshConfig", "default": { "enable": false, "external_port": null, "host_key_verification": "prompt", "inactivity_timeout": "5m", "keepalive_interval": null, "keys": "./data/keys", "listen": "[::]:2222" } }, "sso_providers": { "type": "array", "default": [], "items": { "$ref": "#/$defs/SsoProviderConfig" } } }, "$defs": { "Duration": { "type": "object", "properties": { "nanos": { "type": "integer", "format": "uint32", "minimum": 0 }, "secs": { "type": "integer", "format": "uint64", "minimum": 0 } }, "required": [ "secs", "nanos" ] }, "HttpConfig": { "type": "object", "properties": { "certificate": { "type": "string", "default": "" }, "cookie_max_age": { "type": "string", "default": "1day" }, "external_port": { "type": [ "integer", "null" ], "format": "uint16", "default": null, "maximum": 65535, "minimum": 0 }, "key": { "type": "string", "default": "" }, "listen": { "$ref": "#/$defs/ListenEndpoint", "default": "[::]:8888" }, "session_max_age": { "type": "string", "default": "30m" }, "sni_certificates": { "type": "array", "default": [], "items": { "$ref": "#/$defs/SniCertificateConfig" } }, "trust_x_forwarded_headers": { "type": "boolean", "default": false } } }, "KubernetesConfig": { "type": "object", "properties": { "certificate": { "type": "string", "default": "" }, "enable": { "type": "boolean", "default": false }, "external_port": { "type": [ "integer", "null" ], "format": "uint16", "default": null, "maximum": 65535, "minimum": 0 }, "key": { "type": "string", "default": "" }, "listen": { "$ref": "#/$defs/ListenEndpoint", "default": "[::]:8443" }, "session_max_age": { "type": "string", "default": "30m" } } }, "ListenEndpoint": { "type": "string" }, "LogConfig": { "type": "object", "properties": { "format": { "$ref": "#/$defs/LogFormat", "default": "text" }, "retention": { "type": "string", "default": "7days" }, "send_to": { "type": [ "string", "null" ], "default": null } } }, "LogFormat": { "type": "string", "enum": [ "text", "json" ] }, "MySqlConfig": { "type": "object", "properties": { "certificate": { "type": "string", "default": "" }, "enable": { "type": "boolean", "default": false }, "external_port": { "type": [ "integer", "null" ], "format": "uint16", "default": null, "maximum": 65535, "minimum": 0 }, "key": { "type": "string", "default": "" }, "listen": { "$ref": "#/$defs/ListenEndpoint", "default": "[::]:33306" } } }, "PostgresConfig": { "type": "object", "properties": { "certificate": { "type": "string", "default": "" }, "enable": { "type": "boolean", "default": false }, "external_port": { "type": [ "integer", "null" ], "format": "uint16", "default": null, "maximum": 65535, "minimum": 0 }, "key": { "type": "string", "default": "" }, "listen": { "$ref": "#/$defs/ListenEndpoint", "default": "[::]:55432" } } }, "RecordingsConfig": { "type": "object", "properties": { "enable": { "type": "boolean", "default": false }, "path": { "type": "string", "default": "./data/recordings" } } }, "RoleMapping": { "description": "A role mapping value that accepts either a single role or a list of roles.\n In YAML config: `\"group\": \"role\"` or `\"group\": [\"role1\", \"role2\"]`", "anyOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, "SniCertificateConfig": { "type": "object", "properties": { "certificate": { "type": "string" }, "key": { "type": "string" } }, "required": [ "certificate", "key" ] }, "SshConfig": { "type": "object", "properties": { "enable": { "type": "boolean", "default": false }, "external_port": { "type": [ "integer", "null" ], "format": "uint16", "default": null, "maximum": 65535, "minimum": 0 }, "host_key_verification": { "$ref": "#/$defs/SshHostKeyVerificationMode", "default": "prompt" }, "inactivity_timeout": { "type": "string", "default": "5m" }, "keepalive_interval": { "anyOf": [ { "$ref": "#/$defs/Duration" }, { "type": "null" } ], "default": null }, "keys": { "type": "string", "default": "./data/keys" }, "listen": { "$ref": "#/$defs/ListenEndpoint", "default": "[::]:2222" } } }, "SshHostKeyVerificationMode": { "type": "string", "enum": [ "prompt", "auto_accept", "auto_reject" ] }, "SsoInternalProviderConfig": { "oneOf": [ { "type": "object", "properties": { "type": { "type": "string", "const": "google" }, "admin_email": { "description": "A Google Workspace admin email for domain-wide delegation", "type": [ "string", "null" ] }, "admin_role_mappings": { "type": [ "object", "null" ], "additionalProperties": { "$ref": "#/$defs/RoleMapping" } }, "client_id": { "type": "string" }, "client_secret": { "type": "string" }, "role_mappings": { "description": "Maps Google group email addresses to Warpgate role names.\n Use \"*\" as a key to set a default role for any group not explicitly mapped.", "type": [ "object", "null" ], "additionalProperties": { "$ref": "#/$defs/RoleMapping" } }, "service_account_email": { "description": "Service account email for Google Directory API group lookups", "type": [ "string", "null" ] }, "service_account_key": { "description": "PEM private key from the service account JSON key file", "type": [ "string", "null" ] } }, "required": [ "type", "client_id", "client_secret" ] }, { "type": "object", "properties": { "type": { "type": "string", "const": "apple" }, "client_id": { "type": "string" }, "client_secret": { "type": "string" }, "key_id": { "type": "string" }, "team_id": { "type": "string" } }, "required": [ "type", "client_id", "client_secret", "key_id", "team_id" ] }, { "type": "object", "properties": { "type": { "type": "string", "const": "azure" }, "client_id": { "type": "string" }, "client_secret": { "type": "string" }, "tenant": { "type": "string" } }, "required": [ "type", "client_id", "client_secret", "tenant" ] }, { "type": "object", "properties": { "type": { "type": "string", "const": "custom" }, "additional_trusted_audiences": { "type": [ "array", "null" ], "items": { "type": "string" } }, "admin_role_mappings": { "type": [ "object", "null" ], "additionalProperties": { "$ref": "#/$defs/RoleMapping" } }, "client_id": { "type": "string" }, "client_secret": { "type": "string" }, "issuer_url": { "type": "string" }, "role_mappings": { "type": [ "object", "null" ], "additionalProperties": { "$ref": "#/$defs/RoleMapping" } }, "scopes": { "type": "array", "items": { "type": "string" } }, "trust_unknown_audiences": { "type": "boolean", "default": false } }, "required": [ "type", "client_id", "client_secret", "issuer_url", "scopes" ] } ] }, "SsoProviderConfig": { "type": "object", "properties": { "auto_create_users": { "type": "boolean", "default": false }, "default_credential_policy": { "description": "Default credential policy for auto-created users.\n Keys: \"http\", \"ssh\", \"mysql\", \"postgres\"\n Values: list of credential kinds e.g. [\"sso\"], [\"web\"], []" }, "label": { "type": [ "string", "null" ] }, "name": { "type": "string" }, "provider": { "$ref": "#/$defs/SsoInternalProviderConfig" }, "return_domain_whitelist": { "type": [ "array", "null" ], "items": { "type": "string" } }, "return_url_prefix": { "$ref": "#/$defs/SsoProviderReturnUrlPrefix", "default": "@" } }, "required": [ "name", "provider" ] }, "SsoProviderReturnUrlPrefix": { "type": "string", "enum": [ "@", "_" ] } } } ================================================ FILE: deny.toml ================================================ # 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 # Root options # The graph table configures how the dependency graph is constructed and thus # which crates the checks are performed against [graph] # 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 = [ "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "aarch64-apple-darwin", ] # When creating the dependency graph used as the source of truth when checks are # executed, this field can be used to prune crates from the graph, removing them # from the view of cargo-deny. This is an extremely heavy hammer, as if a crate # is pruned from the graph, all of its dependencies will also be pruned unless # they are connected to another crate in the graph that hasn't been pruned, # so it should be used with care. The identifiers are [Package ID Specifications] # (https://doc.rust-lang.org/cargo/reference/pkgid-spec.html) #exclude = [] # If true, metadata will be collected with `--all-features`. Note that this can't # be toggled off if true, if you want to conditionally enable `--all-features` it # is recommended to pass `--all-features` on the cmd line instead all-features = false # If true, metadata will be collected with `--no-default-features`. The same # caveat with `all-features` applies no-default-features = false # If set, these feature will be enabled when collecting metadata. If `--features` # is specified on the cmd line they will take precedence over this option. #features = [] # The output table provides options for how/if diagnostics are outputted [output] # When outputting inclusion graphs in diagnostics that include features, this # option can be used to specify the depth at which feature edges will be added. # This option is included since the graphs can be quite large and the addition # of features from the crate(s) to all of the graph roots can be far too verbose. # This option can be overridden via `--feature-depth` on the cmd line feature-depth = 1 # 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 databases are cloned/fetched into #db-path = "$CARGO_HOME/advisory-dbs" # The url(s) of the advisory databases to use #db-urls = ["https://github.com/rustsec/advisory-db"] # A list of advisory IDs to ignore. Note that ignored advisories will still # output a note when they are encountered. ignore = [ "RUSTSEC-2023-0071", "RUSTSEC-2021-0139", # ansi-term is unmaintained "RUSTSEC-2025-0134", # rustls-pemfile is deprecated but poem is still using it ] # If this is true, then cargo deny will use the git executable to fetch advisory database. # If this is false, then it uses a built-in git library. # Setting this to true can be helpful if you have special authentication requirements that cargo-deny does not support. # See Git Authentication for more information about setting up git authentication. #git-fetch-with-cli = true # 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 = "warn" # 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" # The default lint level for `default` features for crates that are members of # the workspace that is being checked. This can be overridden by allowing/denying # `default` on a crate-by-crate basis if desired. workspace-default-features = "warn" # The default lint level for `default` features for external crates that are not # members of the workspace. This can be overridden by allowing/denying `default` # on a crate-by-crate basis if desired. external-default-features = "allow" # List of crates that are allowed. Use with care! allow = [ #"ansi_term@0.11.0", #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is allowed" }, ] # List of crates to deny deny = [ "openssl-sys" #"ansi_term@0.11.0", #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is banned" }, # Wrapper crates can optionally be specified to allow the crate when it # is a direct dependency of the otherwise banned crate #{ crate = "ansi_term@0.11.0", wrappers = ["this-crate-directly-depends-on-ansi_term"] }, ] # TODO reenable once poem updates its tokio-rustls dependency # [[bans.features]] # crate = "rustls" # # Features to not allow # deny = ["ring"] [[bans.features]] crate = "reqwest" # Features to not allow deny = ["rustls-tls-webpki-roots"] # Features to allow #allow = [ # "rustls", # "__rustls", # "__tls", # "hyper-rustls", # "rustls", # "rustls-pemfile", # "rustls-tls-webpki-roots", # "tokio-rustls", # "webpki-roots", #] # If true, the allowed features must exactly match the enabled feature set. If # this is set there is no point setting `deny` #exact = true # Certain crates/versions that will be skipped when doing duplicate detection. # skip = [ #"ansi_term@0.11.0", #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason why it can't be updated/removed" }, # ] # 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 = [ #"ansi_term@0.11.0", # will be skipped along with _all_ of its direct and transitive dependencies #{ crate = "ansi_term@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 = [] [sources.allow-org] # github.com organizations to allow git sources for github = [] # gitlab.com organizations to allow git sources for gitlab = [] # bitbucket.org organizations to allow git sources for bitbucket = [] [licenses] confidence-threshold = 0.95 allow = [ "MIT", "Apache-2.0", "Unicode-3.0", "ISC", "OpenSSL", "BSD-2-Clause", "BSD-3-Clause", "Zlib", "WTFPL", "CC0-1.0", "LGPL-3.0", "MPL-2.0", "CDLA-Permissive-2.0", ] [[licenses.clarify]] crate = "ring" expression = "OpenSSL" license-files = [ { path = "LICENSE", hash = 0xbd0eed23 }, ] [[licenses.clarify]] crate = "webpki" expression = "ISC" license-files = [ { path = "LICENSE", hash = 0x001c7e6c }, ] ================================================ FILE: docker/Dockerfile ================================================ # syntax=docker/dockerfile:1.3-labs # hadolint global ignore=DL3008 FROM rust:1.94.0-bullseye@sha256:16950191527a4cb9e0762d9d48b705a6315158e4035e64f7a93ce8656a1b053c AS build ENV DEBIAN_FRONTEND=noninteractive SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ && apt-get update \ && apt-get install -y --no-install-recommends ca-certificates-java nodejs openjdk-17-jdk \ && rm -rf /var/lib/apt/lists/* \ && cargo install just COPY . /opt/warpgate # Needed to correctly embed the version number and the dirty state flag COPY .git/ /opt/warpgate/.git/ # for rust-embed determinism ENV SOURCE_DATE_EPOCH=0 WORKDIR /opt/warpgate RUN just npm ci \ && just openapi \ && just npm run build \ && cargo build --features mysql,postgres --release FROM debian:bullseye-20260316@sha256:943d97fa707482c24e1bc2bdd0b0adc45f75eb345c61dc4272c4157f9a2cc9cc LABEL maintainer=heywoodlh ARG USER_ID=1000 ENV DEBIAN_FRONTEND=noninteractive RUN </ envFromSecret: WARPGATE_ADMIN_PASSWORD: "warpgate-secret/adminPassword" # Config options (overridden if overrides_config is used) disableIpV6: false recordSessions: true databaseUrl: "" # Optional: PostgreSQL connection string # Ports to expose. Set to 0 or false to disable protocol. ssh: 2222 http: 8888 mysql: 33306 pgsql: 55432 kubernetes: 0 # kubernetes: 6443 # Resources for init container (runs setup) resources: {} # limits: # cpu: 100m # memory: 128Mi # requests: # cpu: 100m # memory: 128Mi # Kubernetes Service definition service: annotations: {} type: ClusterIP ports: ssh: 2222 http: 8888 mysql: 33306 pgsql: 55432 kubernetes: 0 # kubernetes: 6443 # Ingress configuration ingress: enabled: false className: "nginx" annotations: cert-manager.io/cluster-issuer: default-issuer kubernetes.io/ingress.class: nginx # kubernetes.io/tls-acme: "true" hosts: [] tls: [] # HTTPRoute configuration httpRoute: enabled: false annotations: {} parentRefs: [] # - name: gateway-name # namespace: gateway-namespace # sectionName: gateway-section-name hostnames: [] # - warpgate.example.com ================================================ FILE: justfile ================================================ projects := "warpgate warpgate-admin warpgate-common warpgate-db-entities warpgate-db-migrations warpgate-database-protocols warpgate-protocol-ssh warpgate-protocol-mysql warpgate-protocol-postgres warpgate-protocol-kubernetes warpgate-protocol-http warpgate-core warpgate-sso" run $RUST_BACKTRACE='1' *ARGS='run': cargo run --all-features -- --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 --all-features -p $p {{ARGS}}; done clippy *ARGS: for p in {{projects}}; do cargo cranky --all-features -p $p {{ARGS}}; done test: for p in {{projects}}; do cargo test --all-features -p $p; done npm *ARGS: cd warpgate-web && npm {{ARGS}} npx *ARGS: cd warpgate-web && npx {{ARGS}} migrate *ARGS: cargo run --all-features -p warpgate-db-migrations -- {{ARGS}} lint *ARGS: cd warpgate-web && npm run lint {{ARGS}} svelte-check: cd warpgate-web && npm run check openapi-all: cd warpgate-web && npm run openapi:schema:admin && npm run openapi:schema:gateway && npm run openapi:client:admin && npm run openapi:client:gateway openapi: cd warpgate-web && npm run openapi:client:admin && npm run openapi:client:gateway config-schema: cargo run -p warpgate-common --bin config-schema > config-schema.json cleanup: (fix "--allow-dirty") (clippy "--fix" "--allow-dirty") fmt svelte-check lint udeps: cargo udeps --all-features --all-targets ================================================ FILE: rust-toolchain ================================================ nightly-2025-10-21 ================================================ FILE: rustfmt.toml ================================================ imports_granularity = "Module" group_imports = "StdExternalCrate" ================================================ FILE: sonar-project.properties ================================================ sonar.projectKey=warp-tech_warpgate sonar.organization=warp-tech sonar.sources=. sonar.inclusions=warpgate-*/**/* sonar.rust.lcov.reportPaths=coverage.lcov ================================================ FILE: tests/.gitignore ================================================ api_sdk ================================================ FILE: tests/Makefile ================================================ 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 . image-postgres-server: cd images/postgres-server && docker build -t warpgate-e2e-postgres-server . all: image-ssh-server image-mysql-server image-postgres-server ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/api_client.py ================================================ from contextlib import contextmanager try: # in-IDE import api_sdk.openapi_client as sdk except ImportError: import openapi_client as sdk @contextmanager def admin_client(host, token="token-value"): config = sdk.Configuration( host=f"{host}/@warpgate/admin/api", api_key={ "TokenSecurityScheme": token, }, ) config.verify_ssl = False with sdk.ApiClient(config) as api_client: yield sdk.DefaultApi(api_client) ================================================ FILE: tests/certs/tls.certificate.pem ================================================ -----BEGIN CERTIFICATE----- MIIBYjCCAQmgAwIBAgIJAKXIp8GepnCzMAoGCCqGSM49BAMCMCExHzAdBgNVBAMM FnJjZ2VuIHNlbGYgc2lnbmVkIGNlcnQwIBcNNzUwMTAxMDAwMDAwWhgPNDA5NjAx MDEwMDAwMDBaMCExHzAdBgNVBAMMFnJjZ2VuIHNlbGYgc2lnbmVkIGNlcnQwWTAT BgcqhkjOPQIBBggqhkjOPQMBBwNCAARAtRfTqyH8+eXf12Vftm6VcMhhYG6Ape3O tcLfIWJo1krsOP+96r5U20ya7YVVFmYFPoQToAOoio2dxlX3jOL/oygwJjAkBgNV HREEHTAbgg53YXJwZ2F0ZS5sb2NhbIIJbG9jYWxob3N0MAoGCCqGSM49BAMCA0cA MEQCICTt3I/PsgF8Rvu6aKwY2LTouZyxReDMiCePzsqdAxXAAiATNw61MBylNaAF FGkPqR0VZIR6sIFHZnib9JQNhka2Fg== -----END CERTIFICATE----- ================================================ FILE: tests/certs/tls.key.pem ================================================ -----BEGIN PRIVATE KEY----- MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg0tJmr/OSF7neTQOV gQn+qHCdVsOENdMc86RlWPiWDlKhRANCAARAtRfTqyH8+eXf12Vftm6VcMhhYG6A pe3OtcLfIWJo1krsOP+96r5U20ya7YVVFmYFPoQToAOoio2dxlX3jOL/ -----END PRIVATE KEY----- ================================================ FILE: tests/conftest.py ================================================ import logging import os import time import psutil import pytest import requests import shutil import signal import subprocess import tempfile import urllib3 import uuid import base64 # cryptography is used to generate client certificates/CSRs locally from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.x509.oid import NameOID from dataclasses import dataclass from pathlib import Path from textwrap import dedent from typing import List, Optional from deepmerge import always_merger from .util import _wait_timeout, alloc_port, wait_port from .test_http_common import echo_server_port # noqa cargo_root = Path(os.getcwd()).parent enable_coverage = os.getenv("ENABLE_COVERAGE", "0") == "1" binary_path = ( "target/llvm-cov-target/debug/warpgate" if enable_coverage else "target/debug/warpgate" ) @dataclass class Context: tmpdir: Path @dataclass class K3sInstance: port: int token: str container_name: str client_cert: str client_key: str def kubectl(self, cmd_args, input=None, check=True): ret = subprocess.run( ["docker", "exec", "-i", self.container_name, "kubectl", *cmd_args], input=input, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) if check: try: ret.check_returncode() except subprocess.CalledProcessError as e: logging.error( f"kubectl command failed: {' '.join(cmd_args)}\nstdout: {e.stdout.decode()}\nstderr: {e.stderr.decode()}" ) raise return ret @dataclass class Child: process: subprocess.Popen stop_signal: signal.Signals stop_timeout: float @dataclass class WarpgateProcess: config_path: Path process: subprocess.Popen http_port: int ssh_port: int mysql_port: int postgres_port: int kubernetes_port: int class ProcessManager: children: List[Child] def __init__(self, ctx: Context, timeout: int) -> None: self.children = [] self.ctx = ctx self.timeout = timeout 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=[], extra_config=""): 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 LogLevel DEBUG3 {extra_config} """ ) ) 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_postgres_server(self): port = alloc_port() container_name = f"warpgate-e2e-postgres-server-{uuid.uuid4()}" self.start( [ "docker", "run", "--rm", "--name", container_name, "-p", f"{port}:5432", "warpgate-e2e-postgres-server", ] ) def wait_postgres(): while True: try: subprocess.check_call( [ "docker", "exec", container_name, "pg_isready", "-h", "localhost", "-U", "user", ] ) break except subprocess.CalledProcessError: time.sleep(1) _wait_timeout(wait_postgres, "Postgres is not ready", timeout=self.timeout) logging.debug(f"Postgres {container_name} is up") return port def start_k3s(self) -> K3sInstance: """ Runs a privileged k3s container, waits for the API to be ready, creates a ServiceAccount and clusterrolebinding, then uses `kubectl create token` to fetch the bearer token. Assumes a modern k8s version (no fallback logic needed). """ port = alloc_port() container_name = f"warpgate-e2e-k3s-{uuid.uuid4()}" image = os.getenv("K3S_IMAGE", "rancher/k3s:v1.35.2-k3s1") self.start( [ "docker", "run", "--rm", "--name", container_name, "--privileged", "-p", f"{port}:6443", image, "server", "--disable", "traefik", ] ) def wait_k3s(): # Wait until kube-apiserver is responding while True: try: subprocess.check_call( [ "docker", "exec", container_name, "kubectl", "get", "nodes", ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) break except subprocess.CalledProcessError: time.sleep(1) _wait_timeout(wait_k3s, "k3s API is not ready", timeout=self.timeout * 5) # k3s sometimes returns OK for `get nodes` before namespace controller # has created the "default" namespace. make sure it exists before we # try to create objects inside it. def wait_default_ns(): while True: r = subprocess.run( [ "docker", "exec", container_name, "kubectl", "get", "namespace", "default", ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) if r.returncode: time.sleep(1) else: break _wait_timeout( wait_default_ns, "default namespace is not ready", timeout=self.timeout * 5 ) # Create service account inside the container subprocess.check_call( [ "docker", "exec", container_name, "kubectl", "create", "serviceaccount", "test-sa", "-n", "default", ] ) # Assign cluster admin role so our SA can do anything subprocess.check_call( [ "docker", "exec", container_name, "kubectl", "create", "clusterrolebinding", "test-sa-binding", "--clusterrole=cluster-admin", "--serviceaccount=default:test-sa", ] ) token = ( subprocess.check_output( [ "docker", "exec", container_name, "kubectl", "create", "token", "test-sa", "-n", "default", ], stderr=subprocess.DEVNULL, ) .decode() .strip() ) # generate a client key and CSR locally, then ask the k3s CA to sign it key = rsa.generate_private_key(public_exponent=65537, key_size=2048) csr = ( x509.CertificateSigningRequestBuilder() .subject_name( x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "system:masters")]) ) .sign(key, hashes.SHA256()) ) client_key = key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.TraditionalOpenSSL, encryption_algorithm=serialization.NoEncryption(), ).decode() csr_pem = csr.public_bytes(serialization.Encoding.PEM) # create the CSR resource inside the cluster using kubectl csr_name = "wg-client" csr_yaml = dedent( f""" apiVersion: certificates.k8s.io/v1 kind: CertificateSigningRequest metadata: name: {csr_name} spec: groups: - system:authenticated - system:masters request: {base64.b64encode(csr_pem).decode()} signerName: kubernetes.io/kube-apiserver-client usages: - client auth """ ) subprocess.run( [ "docker", "exec", "-i", container_name, "sh", "-c", "kubectl apply -f -", ], input=csr_yaml.encode(), check=True, ) subprocess.check_call( [ "docker", "exec", container_name, "kubectl", "certificate", "approve", csr_name, ] ) # after approving the CSR the certificate may take a moment to # appear in the resource status def fetch_cert() -> str: while True: cert = subprocess.check_output( [ "docker", "exec", container_name, "sh", "-c", ( f"kubectl get csr {csr_name} -o jsonpath='{{.status.certificate}}' " "| base64 -d" ), ] ).decode() if cert: return cert time.sleep(0.1) client_cert = "" _wait_timeout( fetch_cert, "k3s did not sign CSR", timeout=self.timeout, ) client_cert = fetch_cert() logging.debug("retrieved signed client certificate from k3s") # the cert subject is "system:masters" so bind that user. subprocess.check_call( [ "docker", "exec", container_name, "kubectl", "create", "clusterrolebinding", "wg-cert-binding", "--clusterrole=cluster-admin", "--user=system:masters", ] ) logging.debug(f"k3s {container_name} is up on port {port}") return K3sInstance( port=port, token=token, container_name=container_name, client_cert=client_cert, client_key=client_key, ) def start_oidc_server( self, warpgate_http_port, extra_scopes=None, users_override=None, extra_identity_resources=None, ): port = alloc_port() container_name = f"warpgate-e2e-oidc-mock-{uuid.uuid4()}" oidc_data_dir = self.ctx.tmpdir / f"oidc-{uuid.uuid4()}" oidc_data_dir.mkdir(parents=True) import json as _json allowed_scopes = [ "openid", "profile", "email", "preferred_username", ] if extra_scopes: allowed_scopes.extend(extra_scopes) clients_config = [ { "ClientId": "warpgate-test", "ClientSecrets": ["warpgate-test-secret"], "AllowedGrantTypes": ["authorization_code"], "AllowedScopes": allowed_scopes, "ClientClaimsPrefix": "", "RedirectUris": [ f"https://127.0.0.1:{warpgate_http_port}/@warpgate/api/sso/return" ], } ] clients_config_path = oidc_data_dir / "clients-config.json" with open(clients_config_path, "w") as f: _json.dump(clients_config, f) server_options = _json.dumps( { "AccessTokenJwtType": "JWT", "Discovery": {"ShowKeySet": True}, "Authentication": { "CookieSameSiteMode": "Lax", "CheckSessionCookieSameSiteMode": "Lax", }, } ) default_users = [ { "SubjectId": "1", "Username": "User1", "Password": "pwd", "Claims": [ { "Type": "name", "Value": "Sam Tailor", "ValueType": "string", }, { "Type": "email", "Value": "sam.tailor@gmail.com", "ValueType": "string", }, { "Type": "preferred_username", "Value": "sam_tailor", "ValueType": "string", }, ], } ] users_config = _json.dumps( users_override if users_override is not None else default_users ) identity_resources_list = [ {"Name": "preferred_username", "ClaimTypes": ["preferred_username"]}, ] if extra_identity_resources: identity_resources_list.extend(extra_identity_resources) identity_resources = _json.dumps(identity_resources_list) self.start( [ "docker", "run", "--rm", "--name", container_name, "-p", f"{port}:8080", "-e", "ASPNETCORE_ENVIRONMENT=Development", "-e", f"SERVER_OPTIONS_INLINE={server_options}", "-e", 'LOGIN_OPTIONS_INLINE={"AllowRememberLogin": true}', "-e", f"USERS_CONFIGURATION_INLINE={users_config}", "-e", f"IDENTITY_RESOURCES_INLINE={identity_resources}", "-e", "CLIENTS_CONFIGURATION_PATH=/tmp/config/clients-config.json", "-v", f"{oidc_data_dir}:/tmp/config:ro", "ghcr.io/soluto/oidc-server-mock:0.10.1", ] ) def wait_oidc(): import urllib3 urllib3.disable_warnings() while True: try: r = requests.get( f"http://localhost:{port}/.well-known/openid-configuration", timeout=2, ) if r.status_code == 200: break except Exception: pass time.sleep(0.5) _wait_timeout(wait_oidc, "OIDC mock is not ready", timeout=self.timeout * 3) logging.debug(f"OIDC mock {container_name} is up on port {port}") return port def start_wg( self, config_patch=None, args=None, share_with: Optional[WarpgateProcess] = None, stderr=None, stdout=None, http_port=None, ) -> WarpgateProcess: args = args or ["run", "--enable-admin-token"] if share_with: config_path = share_with.config_path ssh_port = share_with.ssh_port mysql_port = share_with.mysql_port postgres_port = share_with.postgres_port http_port = share_with.http_port kubernetes_port = share_with.kubernetes_port else: ssh_port = alloc_port() http_port = http_port or alloc_port() mysql_port = alloc_port() postgres_port = alloc_port() kubernetes_port = alloc_port() data_dir = self.ctx.tmpdir / f"wg-data-{uuid.uuid4()}" data_dir.mkdir(parents=True) keys_dir = data_dir / "ssh-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"), ]: shutil.copy(k, keys_dir / k.name) for k in [ Path("certs/tls.certificate.pem"), Path("certs/tls.key.pem"), ]: shutil.copy(k, data_dir / k.name) config_path = data_dir / "warpgate.yaml" def run(args, env={}): return self.start( [ os.path.join(cargo_root, binary_path), "--config", str(config_path), *args, ], cwd=cargo_root, env={ **os.environ, "LLVM_PROFILE_FILE": f"{cargo_root}/target/llvm-cov-target/warpgate-%m.profraw", "WARPGATE_ADMIN_TOKEN": "token-value", "WARPGATE_UNDER_TEST": "1", **env, }, stop_signal=signal.SIGINT, stop_timeout=5, stderr=stderr, stdout=stdout, ) if not share_with: p = run( [ "unattended-setup", "--ssh-port", str(ssh_port), "--http-port", str(http_port), "--mysql-port", str(mysql_port), "--postgres-port", str(postgres_port), "--kubernetes-port", str(kubernetes_port), "--data-path", data_dir, "--external-host", "external-host", ], env={"WARPGATE_ADMIN_PASSWORD": "123"}, ) p.communicate() assert p.returncode == 0 import yaml config = yaml.safe_load(config_path.open()) config["ssh"]["host_key_verification"] = "auto_accept" if config_patch: always_merger.merge(config, config_patch) with config_path.open("w") as f: yaml.safe_dump(config, f) p = run(args) return WarpgateProcess( process=p, config_path=config_path, ssh_port=ssh_port, http_port=http_port, mysql_port=mysql_port, postgres_port=postgres_port, kubernetes_port=kubernetes_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, ], stdin=subprocess.PIPE, 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 timeout(): t = os.getenv("TIMEOUT", "10") return int(t) @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, timeout, report_generation): mgr = ProcessManager(ctx, timeout) try: yield mgr finally: mgr.stop() @pytest.fixture(scope="session", autouse=True) def report_generation(): if not enable_coverage: yield None return # subprocess.call(['cargo', 'llvm-cov', 'clean', '--workspace']) subprocess.check_call( [ "cargo", "llvm-cov", "run", "--no-cfg-coverage-nightly", "--all-features", "--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 shared_wg(processes: ProcessManager): wg = processes.start_wg() wait_port(wg.http_port, for_process=wg.process, recv=False) wait_port(wg.ssh_port, for_process=wg.process) wait_port(wg.kubernetes_port, for_process=wg.process, recv=False) yield wg # sometimes tests just want a pre‑configured API client for the admin # endpoint. previously everyone called ``admin_client(url)`` directly; # a fixture lets us compute the URL from ``shared_wg`` once and removes # boilerplate from individual tests. from .api_client import admin_client as _admin_client_context @pytest.fixture def admin_client(shared_wg: WarpgateProcess): """Yields a ``sdk.DefaultApi`` instance authenticated with the built-in token and pointing at the running warpgate instance. Usage:: def test_something(shared_wg, admin_client): user = admin_client.create_user(...) """ url = f"https://localhost:{shared_wg.http_port}" with _admin_client_context(url) as api: yield api # ---- @pytest.fixture(scope="session") def wg_c_ed25519_pubkey(): return Path(os.getcwd()) / "ssh-keys/wg/client-ed25519.pub" @pytest.fixture(scope="session") def wg_c_rsa_pubkey(): return Path(os.getcwd()) / "ssh-keys/wg/client-rsa.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) ================================================ FILE: tests/images/mysql-server/Dockerfile ================================================ FROM mariadb:10.8@sha256:456709ab146585d6189da05669b84384518baecd83670c9e5221f8c20a47cf1e ENV MYSQL_DATABASE=db ENV MYSQL_ROOT_PASSWORD=123 ADD init.sql /docker-entrypoint-initdb.d ================================================ FILE: tests/images/mysql-server/init.sql ================================================ CREATE TABLE `db`.`table` ( `id` int(11) NOT NULL, `name` varchar(1023) NOT NULL ) ENGINE=InnoDB; ================================================ FILE: tests/images/postgres-server/Dockerfile ================================================ FROM postgres:17.0@sha256:f176fef320ed02c347e9f85352620945547a9a23038f02b57cf7939a198182ae ENV POSTGRES_DB=db ENV POSTGRES_USER=user ENV POSTGRES_PASSWORD=123 ADD init.sql /docker-entrypoint-initdb.d ================================================ FILE: tests/images/postgres-server/init.sql ================================================ CREATE TABLE tbl ( id int NOT NULL, name text NOT NULL ); ================================================ FILE: tests/images/ssh-server/Dockerfile ================================================ FROM alpine:3.14@sha256:0f2d5c38dd7a4f4f733e688e3a6733cb5ab1ac6e3cb4603a5dd564e5bfb80eed RUN apk add openssh curl RUN passwd -u root ENTRYPOINT ["/usr/sbin/sshd", "-De"] ================================================ FILE: tests/oidc-mock/clients-config.json ================================================ [ { "ClientId": "implicit-mock-client", "Description": "Client for implicit flow", "AllowedGrantTypes": [ "implicit" ], "AllowAccessTokensViaBrowser": true, "RedirectUris": [ "https://warpgate.com/@warpgate/api/sso/return", "https://127.0.0.1:8888/@warpgate/api/sso/return" ], "AllowedScopes": [ "openid", "profile", "email", "warpgate-scope", "preferred_username" ], "IdentityTokenLifetime": 3600, "AccessTokenLifetime": 3600 }, { "ClientId": "client-credentials-mock-client", "ClientSecrets": [ "client-credentials-mock-client-secret" ], "Description": "Client for client credentials flow", "AllowedGrantTypes": [ "authorization_code" ], "AllowedScopes": [ "openid", "profile", "email", "warpgate-scope", "preferred_username" ], "ClientClaimsPrefix": "", "RedirectUris": [ "https://warpgate.com/@warpgate/api/sso/return", "https://127.0.0.1:8888/@warpgate/api/sso/return" ], "Claims": [ { "Type": "string_claim", "Value": "string_claim_value", "ValueType": "string" }, { "Type": "json_claim", "Value": "[\"value1\", \"value2\"]", "ValueType": "json" } ] } ] ================================================ FILE: tests/oidc-mock/docker-compose.yml ================================================ version: '3' services: oidc-server-mock: container_name: oidc-server-mock image: ghcr.io/soluto/oidc-server-mock:0.10.1 platform: linux/amd64 ports: - '4011:8080' environment: ASPNETCORE_ENVIRONMENT: Development SERVER_OPTIONS_INLINE: | { "AccessTokenJwtType": "JWT", "Discovery": { "ShowKeySet": true }, "Authentication": { "CookieSameSiteMode": "Lax", "CheckSessionCookieSameSiteMode": "Lax" } } LOGIN_OPTIONS_INLINE: | { "AllowRememberLogin": true } LOGOUT_OPTIONS_INLINE: | { "AutomaticRedirectAfterSignOut": true } IDENTITY_RESOURCES_INLINE: | - Name: preferred_username ClaimTypes: - preferred_username USERS_CONFIGURATION_INLINE: | [ { "SubjectId":"1", "Username":"User1", "Password":"pwd", "Claims": [ { "Type": "name", "Value": "Sam Tailor", "ValueType": "string" }, { "Type": "email", "Value": "sam.tailor@gmail.com", "ValueType": "string" }, { "Type": "preferred_username", "Value": "sam_tailor", "ValueType": "string" }, ] } ] CLIENTS_CONFIGURATION_PATH: /tmp/config/clients-config.json ASPNET_SERVICES_OPTIONS_INLINE: | { "ForwardedHeadersOptions": { "ForwardedHeaders" : "All" } } volumes: - .:/tmp/config:ro ================================================ FILE: tests/pyproject.toml ================================================ [tool.poetry] name = "tests" version = "0.1.0" description = "" authors = ["Your Name "] [tool.poetry.dependencies] python = "^3.10" pytest = "^8" 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" PyYAML = "^6.0.2" deepmerge = "^2" openapi-client = { path = "./api_sdk", develop = true } aiohttp = "^3.11.18" cryptography = "^46" [tool.poetry.dev-dependencies] flake8 = "^5.0.2" black = "^24" [tool.poetry.group.dev.dependencies] pytest-asyncio = "^0.26.0" pytest-timeout = "^2.4.0" pyright = "^1.1.408" [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" ================================================ FILE: tests/run.sh ================================================ #!/bin/sh set -e cd .. rm target/llvm-cov-target/* || true cargo llvm-cov clean --workspace cargo llvm-cov --no-cfg-coverage-nightly --no-report --workspace --all-features -- --skip agent cd tests RUST_BACKTRACE=1 ENABLE_COVERAGE=1 poetry run pytest --timeout 300 $@ cargo llvm-cov report --html ================================================ FILE: tests/ssh-keys/id_ed25519 ================================================ -----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW QyNTUxOQAAACAz/wkEtOQGGLxHmd1+hD0hOJYri7j+8Oqex4CMnTr26gAAAKC8ieiIvIno iAAAAAtzc2gtZWQyNTUxOQAAACAz/wkEtOQGGLxHmd1+hD0hOJYri7j+8Oqex4CMnTr26g AAAEBe+1fXsb/WtCsxt6nR5fVqIX9WHQqbpiVxxNTy41IsFDP/CQS05AYYvEeZ3X6EPSE4 liuLuP7w6p7HgIydOvbqAAAAHGV1Z2VuZUBFdWdlbmVzLU1CUC5mcml0ei5ib3gB -----END OPENSSH PRIVATE KEY----- ================================================ FILE: tests/ssh-keys/id_ed25519.pub ================================================ ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDP/CQS05AYYvEeZ3X6EPSE4liuLuP7w6p7HgIydOvbq ================================================ FILE: tests/ssh-keys/id_rsa ================================================ -----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn NhAAAAAwEAAQAAAYEAnd9Mz8FnYZm/pXB1J+3Yx8IDsb8vuggGmE/xJZm/H0vuCD4vA/aD s3gdqXLjU1/uyD0J/CQPH4GvNqAkJ8AivM2VhnFsG1QJLklkXEHeBFlT81mxO2t0DB4S6Q QXgbYC7XtyY6gVqRYuMT+WoanzJcYMv3gSGFr/Yn8WnVYl33zsD8YtLl3Ku71+5kykFC/8 /LlxOUY73XzTPVuChETvz7KhKrlBiHhOQwjyx2B1JdGQp5hSrBTSmO3+ImIMsClsQw3fP0 vc7st7L06eOBNTrhPOiQqjyn5MoCfoIRAu6uE7oHyrsLfYiZrJWySz+TdZZNedKfQxWDXB cK5+0p8cYyS/U+yGHRrSjSHtl+qqS8QYCTdlteR/EDSuPO7yE63tRpORg6L5kquAupreH6 rtgO0Hpz/6ZXj9bsAR+Gs+J6MxzrJWeDiAxUAkvg9anYgj3skDFBsfylVe2eey1fQtx0/i iKNjFyrpqHPAUIyJXa5QTplpZPicKMpa+X38q6gfAAAFmAwAuXkMALl5AAAAB3NzaC1yc2 EAAAGBAJ3fTM/BZ2GZv6VwdSft2MfCA7G/L7oIBphP8SWZvx9L7gg+LwP2g7N4Haly41Nf 7sg9CfwkDx+BrzagJCfAIrzNlYZxbBtUCS5JZFxB3gRZU/NZsTtrdAweEukEF4G2Au17cm OoFakWLjE/lqGp8yXGDL94Ehha/2J/Fp1WJd987A/GLS5dyru9fuZMpBQv/Py5cTlGO918 0z1bgoRE78+yoSq5QYh4TkMI8sdgdSXRkKeYUqwU0pjt/iJiDLApbEMN3z9L3O7Ley9Onj gTU64TzokKo8p+TKAn6CEQLurhO6B8q7C32ImayVsks/k3WWTXnSn0MVg1wXCuftKfHGMk v1Pshh0a0o0h7ZfqqkvEGAk3ZbXkfxA0rjzu8hOt7UaTkYOi+ZKrgLqa3h+q7YDtB6c/+m V4/W7AEfhrPiejMc6yVng4gMVAJL4PWp2II97JAxQbH8pVXtnnstX0LcdP4oijYxcq6ahz wFCMiV2uUE6ZaWT4nCjKWvl9/KuoHwAAAAMBAAEAAAGAV8re70XRVOBoR/sy24KUI/oLje QRCXX/HOKP6uYF98SE2YajJKQI91vbuuiN7EaUBjyTeekfk9jNdCY4FPbvGmmFNl+Ky+O+ u0PLENb8PRTj75c4TR/jR/3NbFF/NP3fwOr+YNcPPJl+FJsVDE/zTFVHr455GZw5GzArhl Fq/E5/BAKkC33TCPZHRJDoSeWp3WzOvxgEoJYS7rMd8KpZZfojUBv3ionEk9i9Egzc+KwC soCtsM5fkvX+dmZqQei2UTE7gAQfTdzo7kLrLqeCS5UrVEDEDp94Wf6ZQuEPjKioKfpoHr bPiLhcsIH3N7aBpXzXok4dM2U+UgzQW5HfJtgrkUkvlNV3kgtUEw37X9kOhowt/yd6KBT0 Pn5qUtJtBIHKEBpUfyNlJhH9uA/Wift2N7D0TYDVuiAyzT58BK8pS6F55z8ENvvEux/s56 qZj81FUuGwC9L6xTxya88TOGnEZnVnMFTK8MgbcY1cBZqiKLIgBuusMd5lRCbWaBc5AAAA wQCmKQGwQLaffZg2g9fJMyIA7MGDbqMy1Y5DmiGjATGh+oigMY4+ytgvCy8PrBL9NEZCcN FMwTUHUgjUP7611DygK+lDHly60bvmhP4JEkI5adxkd13jCXq2dNiazLV7mD2ZghkOkGcl hMhd7dmYLL1mzlayEvRKLzvexjmcLS4qFkvDl7mCrUBwsAnr6VbZTIyisz1f3H/u+cbWF5 iSaguuuNL5o1hGCeoCdbAu40e1XOTUQU6kD7GnrV7tATZ8cDMAAADBAMwMsotmUXQkS6fR XEiMjse3pVT1hRIcDNNZOeavY/cByFoOv/flo111YL/+aGOT68daLBeMwAYkUtgAFpNZjt LpRZKK7/sN2pliWgCU2PWb4is4QBvmWVtIIaCDs7YbwnumZJdyQHoG8W4wQG+RqzBu5VsA ylinLCbTuZ3I9oOZcLxfJESmTwl3a9oUquN8C4TmcoAS8MRXd7SHndVxS4rvLkHHV5t2bG EazYY/2VOUVv8Z3FJc35ZGhzb5vVjLnQAAAMEAxhDpQsXyhoP9CZnkd21rOinYvMDY9ZjC AYLJ1k7SWBnUki6ozssnURvPgXUKFdj4xfevNvXQdf6Ctj7b1ghC07gpJ9M4Hq+JJcMPSw l9JQpOMCI46nzbLNeAkXhvvpsuMWJO9L4+e+6LZZHHH665e47/dNFgiuIpUQqQaWdqHWGe /4z5XrNjRprno01TsMAln8q3aEx3naONapHr9t7WoqT14cuo4NHZVb1oejssDa3iyAajEo pLZK1nKRsDPQvrAAAAHGV1Z2VuZUBFdWdlbmVzLU1CUC5mcml0ei5ib3gBAgMEBQY= -----END OPENSSH PRIVATE KEY----- ================================================ FILE: tests/ssh-keys/id_rsa.pub ================================================ ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCd30zPwWdhmb+lcHUn7djHwgOxvy+6CAaYT/Elmb8fS+4IPi8D9oOzeB2pcuNTX+7IPQn8JA8fga82oCQnwCK8zZWGcWwbVAkuSWRcQd4EWVPzWbE7a3QMHhLpBBeBtgLte3JjqBWpFi4xP5ahqfMlxgy/eBIYWv9ifxadViXffOwPxi0uXcq7vX7mTKQUL/z8uXE5RjvdfNM9W4KERO/PsqEquUGIeE5DCPLHYHUl0ZCnmFKsFNKY7f4iYgywKWxDDd8/S9zuy3svTp44E1OuE86JCqPKfkygJ+ghEC7q4TugfKuwt9iJmslbJLP5N1lk150p9DFYNcFwrn7SnxxjJL9T7IYdGtKNIe2X6qpLxBgJN2W15H8QNK487vITre1Gk5GDovmSq4C6mt4fqu2A7QenP/pleP1uwBH4az4nozHOslZ4OIDFQCS+D1qdiCPeyQMUGx/KVV7Z57LV9C3HT+KIo2MXKumoc8BQjIldrlBOmWlk+Jwoylr5ffyrqB8= ================================================ FILE: tests/ssh-keys/wg/client-ed25519 ================================================ -----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW QyNTUxOQAAACDujCSXcfts0V3KEZ/vc02DZ0cypyiOHQyNQgT3z35XbAAAAKBv+t0+b/rd PgAAAAtzc2gtZWQyNTUxOQAAACDujCSXcfts0V3KEZ/vc02DZ0cypyiOHQyNQgT3z35XbA AAAEDDVbCUHpecy/RHT/GFRXSnN+A0uEiI3xYwRuTIpgTXEO6MJJdx+2zRXcoRn+9zTYNn RzKnKI4dDI1CBPfPfldsAAAAGmV1Z2VuZUBFdWdlbmVzLU1CUC0yLmxvY2FsAQID -----END OPENSSH PRIVATE KEY----- ================================================ FILE: tests/ssh-keys/wg/client-ed25519.pub ================================================ ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO6MJJdx+2zRXcoRn+9zTYNnRzKnKI4dDI1CBPfPflds eugene@Eugenes-MBP-2.local ================================================ FILE: tests/ssh-keys/wg/client-rsa ================================================ -----BEGIN PRIVATE KEY----- MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCvukhj4nGf2l5neOjlolntx+42 yaOz8wmKzc8hRMg8N7GrI4ntGcK7nkgfAuN9a4N+AIaluAjPFeJAtGSGovtLro4AUnFsrJMVN8T5 eEhSgE48ko+Limi3JYsC0Vf9YHnVh8t7oioeriSLuyAyTWnykmQIZcYgW1r3bg87Y9qCIceXJQwc jM97czNgckxR4k8a9ja/8kv3spNcNFl7NR7oZ4cyOfbnq5+FJCPohNoviahMtqyVwGVuIXhfTSdo GRe6mRbbHyK2uAHw2MULUX4E18v72cGDIxuWNqbmdjJozlYKaf+xykVZ6+jv+WrsLziKms9SWcw5 4zNuhR2YZn92/yW+bnBqMjtsZFxgvDBHEM0KIZUuvbV84V/hskwFAo3xyNpy9x6LhlJ17/MkKKmf GeQ27RjsI8YJFmmakgSHzpg69eqldf4kgsAGr/M9U4ZNE4nrfQnKUY8AN+68pp8oKYFTtrHJ8m0B Y81SGMxynpjNEJPHBv7gYDDUJY8qAhypIP4pxHRohJu6SiE1OEol4ZtQzEzSKKMxxlkberatEvzc Cq6l7d1KS21cmeAdtl7jSMVYTij77B2sDnsmk5PD769wAWJU5T8JaL4bJid45+UKeZz8VQx3e5mi S/yaGj1uaD6ryhXCZwQh0f9hLvopOGNMEm063qz33lvBys+TDwIDAQABAoICAEO2TxCV/9xtw3Sx hWR+w5I5ONRJrFe5rZKbrVWPcGyrtT1Rq2L+SygKXJX+gfQhCoDx6PBQUqyhLRZrrFSo1pYaA8Oi AOy0LtS9MZxDOfL4V61FeCR3x9PSlpcWXYZXt3qNId5Y5Uv/JDvndgeMBugeeoc12Ds9mHbBJQNo fZkpNQRLlTgnFgfmowRl5nyi7IJiH0SlM5qVZ+zeiyBLnsZEpja3WSl52zTtcRy2nHA25e/xb90g TrU6Fmz6iNW23YrcVI9IlxK7IpxQmtS6qQlqscIw7Tz/uTCPjI4/OzthTowivhEe9MwqeA6IGCg8 JdhawMplqakgn//VMUs5K6HliyJlUwBAHHjGkOMjq7SCS9tvj6jlLrs/2SeoKyv4Q0lWrZb/QJ+L xchYHs5aAeHGtO9VLKfHHC0qesw8udJ7yp2g4RiTWEPO9cGufKAUHYDvMf1qnvgbyhmZ0a/ft16z Yb3RO3orcyFcrHNVuutaouafVrIaJS9F7MZNUiUJzMNz4/fCVP+FAJ+Dbyytlz7rq0p86wlBfq0L k8xK3eoI9cTgb0insgREhwLz3p6KZ4JCnw6rEt2lqEWRkMEAcH1htVtdejp6bq3OMP8PvwViz7Br DdL3OqG1GaNuHHMNW7Py0RqqokXCGQTAFqyTlXomBHOpWOqRxjkCPPJSMWNhAoIBAQDA4VGvDZEy h+k8C4ggt128xNZl7rYZMxJcvVg8/mr80RifDaJfr2SY5DDrbFHX18uBaLPioOcjuoWdMaX6+M9F ZwgEA1LvKQg92rG7UQ/DrjbjPMiALsx3yN/P1dFvjxDkEpCGAcAAXh6C4mCrnqseoDV74TQcLsxt Z4vmL1QifagQbu3xYnyKgO/bPfCiZ4vRyRUCfKv9eoH9dbw4DWyxGXLGNtu77FPXFFeVEoFx7EYG sZiA1Om37hCgceQ7fGqKgSiwwLtBc+WN5gQ7toQ4OkqyWDkaTcvcNRKmVlf/TiGejZRqny4QjnYV nQdkBBrekpoXSCghR410pTyktOYfAoIBAQDpPABQL1LRplhRACpcIA4jHYYVx/C7YhqRkO7rlpG+ 8hBH3kuE9SFOrPKxWQF8qVw2Jwvs24n5loE5+1Yq29b46mKLkbpEumBj5oLtIPNgM9cBWHlnJwPI lCpcxVv4Kvsxb+0eaCuxl2DT5zj5d8V6FrpeuQbmpLdKlfwQX2+6w3SqQjaUleHHWvcoqLFcvcml zVNQrQVt9HvRWsBmg9sHow+CPmF50Z5DfiNPf/vvEegkT3xtcYC6CohIK2/O9R3yjLKZ0Tx9mBse I2M7mbok9L/5PlsGyNa/+I/aM/eodJqRInaTcs3qGmfA2bjedHEdVuhcMcvnGoM26jSBHFURAoIB AQCi8Xa1QOvp2WGTJVbR9LaO02cgY8KYlUms6RSTKoetnuOC8ty6owyEETq2mCKoCpjUcWSOT0oV J+zauGe1Ft7bjcf6w+gbPPnGb2t4iGmd8R5TaDUl/OMlSqCxDrxI1374fipz2ySd6uUxwxbRxVBg pg2o4r7IFE0FG9XXFyKnpKoHf/8pzf7Sb0yyVahlOr6m8o36NOKDWCxauEzSuZyaHJqWkx+cqXDG oVvABwst9+HMo9nm9Heht896C90418mVyrlaYOeQyt0hvDDVVUJr0erqsZdD/nb7SCbCOO1MNHA4 Zvj7/g/HUuK1LZxhxQoB/62Hf6DPRIhfA3yw1FYXAoIBAF2JVarSv8kaiDK7+UEHDgRhM8QKcm4D 0xnr4RWURhEo7QSVjv3cfSYbUB11z5XaKgQBttOf2/6/sEW7mXwIvHcJMMo+gFBN2phV+s30uAYt 5B1DCTUoPWk0mqSn9dFaE3FpLNRT/Kn1RrzU71GFCiqDcOzKEY1wI54C9pruW1WwS1p4wYDndyvH PHYO6UqDRpp69N3W9eV59ioo1h6G5NF0QKUANYFwYqM4tBqO/k+Lg+kEA6e0rGZwEOW4ndeHECKU 8I+ljTflR4LXuFVPuopVqaPgsQrQgudsXOyqiLkDQnXQN3O8x/4J5vA9oNl+I1sb3oYS5m5hgJwG Y1YgMbECggEAenUW6Szz2klOimJbLnJO08bM0WhGWJzCo0yUCqSV1AEb074ogaS8ByhZ4hnz45IV KwCyS/pGogxanTSAcnYzb7ty9N68IIcCkSy7fKcmY6+JIFMtaFTyaxfJuzAfxXZ6UidQfi9eqyWL QlCGVwZoFG7BQsd/xvJ9BinX095JRmKEFKDMktjXCahVi2gHFgYfvFEvSIlclzrIfwGwV4KWdzbA H3Wt6GmdRdlhgDUB9Q/mDtQ579AWM9OeOhNvpy1E2dUUlS3Trr12DDbLbtG1lEWNut+lFRpL3mCW kZb3jgOZ60Y9FeI6G9iGSaMs1YPtoWtSg5lqPWBnTGrPZJBMbQ== -----END PRIVATE KEY----- ================================================ FILE: tests/ssh-keys/wg/client-rsa.pub ================================================ ssh-rsa AAAADHJzYS1zaGEyLTI1NgAAAAMBAAEAAAIBAK+6SGPicZ/aXmd46OWiWe3H7jbJo7PzCYrNzyFEyDw3sasjie0ZwrueSB8C431rg34AhqW4CM8V4kC0ZIai+0uujgBScWyskxU3xPl4SFKATjySj4uKaLcliwLRV/1gedWHy3uiKh6uJIu7IDJNafKSZAhlxiBbWvduDztj2oIhx5clDByMz3tzM2ByTFHiTxr2Nr/yS/eyk1w0WXs1HuhnhzI59uern4UkI+iE2i+JqEy2rJXAZW4heF9NJ2gZF7qZFtsfIra4AfDYxQtRfgTXy/vZwYMjG5Y2puZ2MmjOVgpp/7HKRVnr6O/5auwvOIqaz1JZzDnjM26FHZhmf3b/Jb5ucGoyO2xkXGC8MEcQzQohlS69tXzhX+GyTAUCjfHI2nL3HouGUnXv8yQoqZ8Z5DbtGOwjxgkWaZqSBIfOmDr16qV1/iSCwAav8z1Thk0Tiet9CcpRjwA37rymnygpgVO2scnybQFjzVIYzHKemM0Qk8cG/uBgMNQljyoCHKkg/inEdGiEm7pKITU4SiXhm1DMTNIoozHGWRt6tq0S/NwKrqXt3UpLbVyZ4B22XuNIxVhOKPvsHawOeyaTk8Pvr3ABYlTlPwlovhsmJ3jn5Qp5nPxVDHd7maJL/JoaPW5oPqvKFcJnBCHR/2Eu+ik4Y0wSbTrerPfeW8HKz5MP ================================================ FILE: tests/ssh-keys/wg/host-ed25519 ================================================ -----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW QyNTUxOQAAACDBXU9RmJEViQhZZYvaIyEnEMb1X1VCchYGTbyHqbyK6AAAAKBBjcVuQY3F bgAAAAtzc2gtZWQyNTUxOQAAACDBXU9RmJEViQhZZYvaIyEnEMb1X1VCchYGTbyHqbyK6A AAAEBeZZ1uCofYbG7ypyBBrHGlcggJRbFFFzqGrIxST/B9ksFdT1GYkRWJCFlli9ojIScQ xvVfVUJyFgZNvIepvIroAAAAGmV1Z2VuZUBFdWdlbmVzLU1CUC0yLmxvY2FsAQID -----END OPENSSH PRIVATE KEY----- ================================================ FILE: tests/ssh-keys/wg/host-ed25519.pub ================================================ ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMFdT1GYkRWJCFlli9ojIScQxvVfVUJyFgZNvIepvIro eugene@Eugenes-MBP-2.local ================================================ FILE: tests/ssh-keys/wg/host-rsa ================================================ -----BEGIN PRIVATE KEY----- MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQCxPfQ6f73tfGFRV93Aby+hCQAP QlkOo/4BhG2VoPq4gjcyl5XnhV/zUQFTvcYX/AGbZBI4Wgq70d3R8zH8Xz11Vpj9K+KKpzS3ortk SC5ZN7bOK1eSnlaS7AVrDX2802KIZOuIvxNrgLQeFnMapXPgaSaD/4F6+U48DlmMQ0cgV39rqwmy 1yJjVXBOVmNtZ7Fd2E+5ov3mmR4iLzSs6YYjTd0Atp7EhsKfPv99zLDYQIqeQZJt5kxsWd+0p2Ci G1BfOTUTyD5x4YMN6JAk0GvzH09SF17M+yhascljTlnZuHgIjVIESAEoNFBoGytVhOkFvtc3MSiZ Fdcb38iAbZkEEjHU1K+KCeAJoGG9rblOKV/xrVMXTLVEKa6gKeetuMJhlqXfbpB6bO6aJuPyLRp7 8Ky0l3kpZrKv2fgMa0sHD14HcPqsEQf8pPhW8oxepyTArTHlU738/9wZ4K8HWt/fKRaXcY81Dz1M ywSNKbvoqOJNvZDABkgeABJYsLxVYv4ReXy8plrJkuVNrlX1H0KKmFCt0F51SCGAOHp4wqWn/g0c vz5J3P6E2Bnw1P67VbFnBvRHlDFxtbKMzob56TNutNkpDqQYwBgPz2HVQde3Og2Kem0RCxENsiU7 Z78w8NYfGfUiu5qt8jKICiJ48TCciwoSF5/uFwnzM9glEp6BPQIDAQABAoICABnzeUagx4f11dtE IzrMIiF7oN+bFlEMyox2/a3n3m3qMEzJtxrUA3grxyb3ZVbDq4nl/Wj002zWo0TcooKngJHP9ncz LWihvMKaeBeMc1oqzIBOsPQjF4f244AzfyfeR3yy/MLj6eMBUDNg6W+LBCFlI/eLD0Pt1s+iRj2W 6DDLFDlegf1b1Il4zAhxoP3hgy27a0jsnYJdrvTQYUUOrXjj1f+xvXixm94XKkTFa1XudUgLT8uk Pvz/rRUqtf0Q72lR21H52B1yfcQpO1m4jpBlaDFxLIyUxZQp7dO1eCBnCw7YKkcS3TXWxbhzKfAh QBZ679Cr85wef4VxnvjMPe1an/g26brf9o9ralNawdJyakuaOa57CaoiEwk+ZxcZAjpmvS8c0sI0 ohsiXxouHbZlv6Hrr/ACnFmRp6y+i9wgkk8850GztILnDs5L3pLSDside3VraefuDJ76Qa8ulXIq /0c5aoj3JkvJYOSGIDvnQUpE8rcrRs43Az3GRUtYfkCpQlyqORR+aHgMcxZfTLKxhLdbgUE9BJc8 E33xOW0VIsNX2O7RGpDiaFH3dfL9wMORViFR0B/A8q/TKarU7ni+hVgmTyIg7D0NTnnZNaOSloBr A50iFJxa5YqmpDduaki5WlLzBFauDH5UaeNeAPrT1fYWksrl5IGdOjDxkEE9AoIBAQDUz4jlGW0s hAmafSrtwbVBPBFZ122AuyplibLlHgADsD6Uu3tUKgVd22nCV8wzlpxL9GPG80S5aJPPI6gpl2XH vUV2yWuJLsW5324hdKjxH5mJjWZtA0CpIZ+PdNc3LLolFLgew5HMt1sHZeqFRqxbZLGBmNuXyOme k1SCTVmMuTK8wzWVzDhc2g+u4NL1eRccmS18bGKGA+oNnY/IRdjdPw5gNeBg/6ZVimXj91d0WiFi 6Pr0Bop0g0bpAoWk6s/XskBVQAYcCmNZMI+ll05RgARKYK+Donn/zu/5jGYXQ+9jAv78q796nT2J dRIPAh/azTxu+yE1byaC01iLGTVDAoIBAQDVNnfBHRF2mWLwfpPponXISupn2WApgjX7P8HS9WjN bb/X9gbjNJXIgF+XqpwOxaQFolCHw9uyv7+73PHhDiaSqdw5NkvFtfJkacWlJblybUVVGljaGgpp KFlRqcX8knFwNpDXwnoMttST0eoWZnj5jeWhQkc/8XceN9NPY4fxAwXceMWBAW03p47vMpxC6FGN Et7KlBB5coQWkVZyxjq3n69FouHAQZWEjBmgBpGf16HtvSGIO91hr3PXX5oktvRejN25WQiwLG0O /DHnNvQcovb9QpjZ3fnBfkxQmzqLsdCR/FTsYtvMqnR2OzR/zI3UeRPAphzfWBAG2W6+Esd/AoIB AQC90GqjJd254f+K22/p52hbSk+TmdIjC05SiNKXB/4tTAtVsC/drylgQO+BF7ycmw7HtLE2aA95 bKzCCmTYzCBNWyXVQOz4zE4ybvaVQq/Zej0BcqzUOR14ffQLCcVYgj16C5P6ZKfsN/Mqkx3uSE49 qn+lP4lGRj8SYQj0vDdOjHWT5m4qMaBoOVvZuNCRgLM7n+jxXN8398/Q2yO/F4XKOY8CA6wh+IUN MUeWYSyRLD8xMOt9s0PVjq418TjxEzvVgTlekJ+ibSWWDPljUqTZjtzE1p5WRBqbL6HeLPt2bvLb lnWHO02r+QpFS7WSy2tMRtlLiBVjysNH12jXkOFvAoIBAEyGSiETr8rjbrFmnOwEFUYYLV2slWkQ hRNyZLy0vDLPK0X11a8Clqfp+2VSJMTghuhGw6SW1WmojMZ+nInsLEgDkzktlbCWhzMnC3skuRSq x3GuDSnqosXvZ296AcePQAvIaeAmuuuJS27qrpvvl4fqN/rS8QOwRNKhssQRsx77uMTSzABrZKnP B+wuPAt/mpWJqlEHJ4qPYX1AGMkFANobBCt4NJJud52lMyVOdkHqgQH1Ge3tnp2K/YbVl1uKFtdA s+vsWsPwjgwM1FRqUt9cVk2782Ru2U9rZzSfIjo1Tei3qjtVmBIzM62jvkoIPvd9pWtFs6Mt1kK/ E5JA5z0CggEBAKgsIb3x7mXJXelmyeD3m3KdzIsIqHZ1yo3YUPs6O/2UKwmPMEAcgfBbiIL/Z0Tw GqytCKR8ghKxj/CLHqZne9P+hd0vJrJfJGuita6JC9HI8EVd024FV+QAjZjQHdSUe3NY4h+J9U0h f0r/LIyoWGGKKaPLVxJF5EJa46cixhSp71w4fhlIG/FtTNXVGzkJeh4jrLlKU84RxpprUoS9wwKS gidavaqws6Ks8oVf0oJaqeMk8RrlEeAR1OyXtaxdAdYnHsMKLLoNrEKUAHdBexpJ9fhqlQhZWkT6 xS6VZKFUtFtzffTY0HE+R+ESSmiUSl3mB6gsfiXlu0Q9kxjw3wU= -----END PRIVATE KEY----- ================================================ FILE: tests/test_api_auth.py ================================================ import contextlib from dataclasses import dataclass from typing import Callable, Dict, Optional, Set from json import load from datetime import datetime, timedelta, timezone from pathlib import Path from uuid import uuid4 import pytest import requests from .api_client import sdk, admin_client as new_admin_client from .conftest import WarpgateProcess from .test_http_common import * # noqa @dataclass class AdminApiTestCase: id: str permission: Optional[str] call: Callable[[sdk.DefaultApi, Dict[str, object]], sdk.ApiResponse] expected_statuses: Set[int] @contextlib.contextmanager def assert_401(): with pytest.raises(sdk.ApiException) as e: yield assert e.value.status == 401 def make_limited_admin_role_payload(**overrides): return { "name": overrides.get("name", f"limited-{uuid4()}"), "description": "limited permissions", "targets_create": False, "targets_edit": False, "targets_delete": False, "users_create": False, "users_edit": False, "users_delete": False, "access_roles_create": False, "access_roles_edit": False, "access_roles_delete": False, "access_roles_assign": False, "sessions_view": False, "sessions_terminate": False, "recordings_view": False, "tickets_create": False, "tickets_delete": False, "config_edit": False, "admin_roles_manage": False, **overrides, } ADMIN_API_TEST_CASES: list[AdminApiTestCase] = [ AdminApiTestCase( id="get_sessions", permission="sessions_view", call=lambda api, r: api.get_sessions_with_http_info(), expected_statuses={200}, ), AdminApiTestCase( id="get_session", permission="sessions_view", call=lambda api, r: api.get_session_with_http_info(r["session_id"]), expected_statuses={200, 404}, ), AdminApiTestCase( id="get_session_recordings", permission="recordings_view", call=lambda api, r: api.get_session_recordings_with_http_info(r["session_id"]), expected_statuses={200}, ), AdminApiTestCase( id="close_session", permission="sessions_terminate", call=lambda api, r: api.close_session_with_http_info(r["session_id"]), expected_statuses={200, 404}, ), AdminApiTestCase( id="close_all_sessions", permission="sessions_terminate", call=lambda api, r: api.close_all_sessions_with_http_info(), expected_statuses={201}, ), AdminApiTestCase( id="get_recording", permission="recordings_view", call=lambda api, r: api.get_recording_with_http_info(r["recording_id"]), expected_statuses={200, 404}, ), AdminApiTestCase( id="get_kubernetes_recording", permission="recordings_view", call=lambda api, r: api.get_kubernetes_recording_with_http_info( r["recording_id"] ), expected_statuses={200, 404}, ), AdminApiTestCase( id="get_roles", permission=None, call=lambda api, r: api.get_roles_with_http_info(), expected_statuses={200}, ), AdminApiTestCase( id="create_role", permission="access_roles_create", call=lambda api, r: api.create_role_with_http_info( sdk.RoleDataRequest(name=f"role-{uuid4()}"), ), expected_statuses={201}, ), AdminApiTestCase( id="get_role", permission=None, call=lambda api, r: api.get_role_with_http_info(r["role_id"]), expected_statuses={200}, ), AdminApiTestCase( id="update_role", permission="access_roles_edit", call=lambda api, r: api.update_role_with_http_info( r["role_id"], sdk.RoleDataRequest(name=f"role-{uuid4()}"), ), expected_statuses={200}, ), AdminApiTestCase( id="get_role_targets", permission=None, call=lambda api, r: api.get_role_targets_with_http_info(r["role_id"]), expected_statuses={200}, ), AdminApiTestCase( id="get_role_users", permission=None, call=lambda api, r: api.get_role_users_with_http_info(r["role_id"]), expected_statuses={200}, ), AdminApiTestCase( id="get_admin_roles", permission=None, call=lambda api, r: api.get_admin_roles_with_http_info(), expected_statuses={200}, ), AdminApiTestCase( id="create_admin_role", permission="admin_roles_manage", call=lambda api, r: api.create_admin_role_with_http_info( sdk.AdminRoleDataRequest( **make_limited_admin_role_payload(name=f"admin-role-{uuid4()}") ) ), expected_statuses={201}, ), AdminApiTestCase( id="get_admin_role", permission=None, call=lambda api, r: api.get_admin_role_with_http_info(r["admin_role_id"]), expected_statuses={200}, ), AdminApiTestCase( id="update_admin_role", permission="admin_roles_manage", call=lambda api, r: api.update_admin_role_with_http_info( r["admin_role_id"], sdk.AdminRoleDataRequest( **make_limited_admin_role_payload(name=f"admin-role-{uuid4()}") ), ), expected_statuses={200}, ), AdminApiTestCase( id="get_admin_role_users", permission=None, call=lambda api, r: api.get_admin_role_users_with_http_info(r["admin_role_id"]), expected_statuses={200}, ), AdminApiTestCase( id="get_tickets", permission=None, call=lambda api, r: api.get_tickets_with_http_info(), expected_statuses={200}, ), AdminApiTestCase( id="create_ticket", permission="tickets_create", call=lambda api, r: api.create_ticket_with_http_info( sdk.CreateTicketRequest( username=r["username"], target_name=r["target_name"], ), ), expected_statuses={201}, ), AdminApiTestCase( id="delete_ticket", permission="tickets_delete", call=lambda api, r: api.delete_ticket_with_http_info(r["ticket_id"]), expected_statuses={204}, ), AdminApiTestCase( id="add_ssh_known_host", permission="config_edit", call=lambda api, r: api.add_ssh_known_host_with_http_info( sdk.AddSshKnownHostRequest( host="127.0.0.1", port=22, key_type="ecdsa-sha2-nistp256", key_base64="AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKL5C+OCN2hAbPoR+mwG4M402Z0XVDOuV5k7n6zCRIMsgnYiyz6a61Zcw/RRHoQAb7ndqUyk8eAi9gjPEiGq2d0=", ), ), expected_statuses={200}, ), AdminApiTestCase( id="get_ssh_known_hosts", permission="config_edit", call=lambda api, r: api.get_ssh_known_hosts_with_http_info(), expected_statuses={200}, ), AdminApiTestCase( id="delete_ssh_known_host", permission="config_edit", call=lambda api, r: api.delete_ssh_known_host_with_http_info( r["ssh_known_host_id"] ), expected_statuses={204, 404}, ), AdminApiTestCase( id="get_ssh_own_keys", permission=None, call=lambda api, r: api.get_ssh_own_keys_with_http_info(), expected_statuses={200}, ), AdminApiTestCase( id="get_logs", permission=None, call=lambda api, r: api.get_logs_with_http_info(sdk.GetLogsRequest(search="")), expected_statuses={200}, ), AdminApiTestCase( id="get_targets", permission=None, call=lambda api, r: api.get_targets_with_http_info(), expected_statuses={200}, ), AdminApiTestCase( id="create_target", permission="targets_create", call=lambda api, r: api.create_target_with_http_info( sdk.TargetDataRequest( name=f"target-{uuid4()}", options=sdk.TargetOptions( sdk.TargetOptionsTargetSSHOptions( kind="Ssh", host="127.0.0.1", port=22, username="user", auth=sdk.SSHTargetAuth( sdk.SSHTargetAuthSshTargetPublicKeyAuth(kind="PublicKey") ), ) ), ), ), expected_statuses={201}, ), AdminApiTestCase( id="get_target", permission=None, call=lambda api, r: api.get_target_with_http_info(r["target_id"]), expected_statuses={200}, ), AdminApiTestCase( id="update_target", permission="targets_edit", call=lambda api, r: api.update_target_with_http_info( r["target_id"], sdk.TargetDataRequest( name=f"target-{uuid4()}", options=sdk.TargetOptions( sdk.TargetOptionsTargetSSHOptions( kind="Ssh", host="127.0.0.1", port=22, username="user", auth=sdk.SSHTargetAuth( sdk.SSHTargetAuthSshTargetPublicKeyAuth(kind="PublicKey") ), ) ), ), ), expected_statuses={200}, ), AdminApiTestCase( id="get_ssh_target_known_ssh_host_keys", permission="targets_edit", call=lambda api, r: api.get_ssh_target_known_ssh_host_keys_with_http_info( r["target_id"] ), expected_statuses={200}, ), AdminApiTestCase( id="get_target_roles", permission=None, call=lambda api, r: api.get_target_roles_with_http_info(r["target_id"]), expected_statuses={200}, ), AdminApiTestCase( id="add_target_role", permission="access_roles_assign", call=lambda api, r: api.add_target_role_with_http_info( r["target_id"], r["role_id"] ), expected_statuses={201, 409}, ), AdminApiTestCase( id="delete_target_role", permission="access_roles_assign", call=lambda api, r: api.delete_target_role_with_http_info( r["target_id"], r["role_id"] ), expected_statuses={204}, ), AdminApiTestCase( id="list_target_groups", permission=None, call=lambda api, r: api.list_target_groups_with_http_info(), expected_statuses={200}, ), AdminApiTestCase( id="create_target_group", permission="targets_create", call=lambda api, r: api.create_target_group_with_http_info( sdk.TargetGroupDataRequest(name=f"group-{uuid4()}"), ), expected_statuses={201}, ), AdminApiTestCase( id="get_target_group", permission=None, call=lambda api, r: api.get_target_group_with_http_info(r["target_group_id"]), expected_statuses={200}, ), AdminApiTestCase( id="update_target_group", permission="targets_edit", call=lambda api, r: api.update_target_group_with_http_info( r["target_group_id"], sdk.TargetGroupDataRequest(name=f"group-{uuid4()}"), ), expected_statuses={200}, ), AdminApiTestCase( id="delete_target_group", permission="targets_delete", call=lambda api, r: api.delete_target_group_with_http_info( r["target_group_id"] ), expected_statuses={204}, ), AdminApiTestCase( id="get_users", permission=None, call=lambda api, r: api.get_users_with_http_info(), expected_statuses={200}, ), AdminApiTestCase( id="create_user", permission="users_create", call=lambda api, r: api.create_user_with_http_info( sdk.CreateUserRequest(username=f"user-{uuid4()}"), ), expected_statuses={201}, ), AdminApiTestCase( id="get_user", permission=None, call=lambda api, r: api.get_user_with_http_info(r["user_id"]), expected_statuses={200}, ), AdminApiTestCase( id="update_user", permission="users_edit", call=lambda api, r: api.update_user_with_http_info( r["user_id"], sdk.UserDataRequest(username=f"user-{uuid4()}"), ), expected_statuses={200}, ), AdminApiTestCase( id="unlink_user_from_ldap", permission="users_edit", call=lambda api, r: api.unlink_user_from_ldap_with_http_info(r["user_id"]), expected_statuses={200, 400}, ), AdminApiTestCase( id="auto_link_user_to_ldap", permission="users_edit", call=lambda api, r: api.auto_link_user_to_ldap_with_http_info(r["user_id"]), expected_statuses={200, 400}, ), AdminApiTestCase( id="get_user_roles", permission=None, call=lambda api, r: api.get_user_roles_with_http_info(r["user_id"]), expected_statuses={200}, ), AdminApiTestCase( id="add_user_role", permission="access_roles_assign", call=lambda api, r: api.add_user_role_with_http_info( r["user_id"], r["role_id"] ), expected_statuses={201}, ), AdminApiTestCase( id="delete_user_role", permission="access_roles_assign", call=lambda api, r: api.delete_user_role_with_http_info( r["user_id"], r["role_id"] ), expected_statuses={204}, ), AdminApiTestCase( id="get_user_admin_roles", permission=None, call=lambda api, r: api.get_user_admin_roles_with_http_info(r["user_id"]), expected_statuses={200}, ), AdminApiTestCase( id="add_user_admin_role", permission="admin_roles_manage", call=lambda api, r: api.add_user_admin_role_with_http_info( r["user_id"], r["admin_role_id"] ), expected_statuses={201}, ), AdminApiTestCase( id="delete_user_admin_role", permission="admin_roles_manage", call=lambda api, r: api.delete_user_admin_role_with_http_info( r["user_id"], r["admin_role_id"] ), expected_statuses={204}, ), AdminApiTestCase( id="get_password_credentials", permission="users_edit", call=lambda api, r: api.get_password_credentials_with_http_info(r["user_id"]), expected_statuses={200}, ), AdminApiTestCase( id="create_password_credential", permission="users_edit", call=lambda api, r: api.create_password_credential_with_http_info( r["user_id"], sdk.NewPasswordCredential(password="123"), ), expected_statuses={201}, ), AdminApiTestCase( id="delete_password_credential", permission="users_edit", call=lambda api, r: api.delete_password_credential_with_http_info( r["user_id"], r["password_id"] ), expected_statuses={204}, ), AdminApiTestCase( id="get_sso_credentials", permission="users_edit", call=lambda api, r: api.get_sso_credentials_with_http_info(r["user_id"]), expected_statuses={200}, ), AdminApiTestCase( id="create_sso_credential", permission="users_edit", call=lambda api, r: api.create_sso_credential_with_http_info( r["user_id"], sdk.NewSsoCredential(email="test@example.com", provider="test"), ), expected_statuses={201}, ), AdminApiTestCase( id="update_sso_credential", permission="users_edit", call=lambda api, r: api.update_sso_credential_with_http_info( r["user_id"], r["sso_id"], sdk.NewSsoCredential(email="test@example.com", provider="test"), ), expected_statuses={200}, ), AdminApiTestCase( id="delete_sso_credential", permission="users_edit", call=lambda api, r: api.delete_sso_credential_with_http_info( r["user_id"], r["sso_id"] ), expected_statuses={204}, ), AdminApiTestCase( id="get_public_key_credentials", permission="users_edit", call=lambda api, r: api.get_public_key_credentials_with_http_info(r["user_id"]), expected_statuses={200}, ), AdminApiTestCase( id="create_public_key_credential", permission="users_edit", call=lambda api, r: api.create_public_key_credential_with_http_info( r["user_id"], sdk.NewPublicKeyCredential( label="key", openssh_public_key="ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKL5C+OCN2hAbPoR+mwG4M402Z0XVDOuV5k7n6zCRIMsgnYiyz6a61Zcw/RRHoQAb7ndqUyk8eAi9gjPEiGq2d0=", ), ), expected_statuses={201}, ), AdminApiTestCase( id="update_public_key_credential", permission="users_edit", call=lambda api, r: api.update_public_key_credential_with_http_info( r["user_id"], r["public_key_id"], sdk.NewPublicKeyCredential( label="key", openssh_public_key="ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKL5C+OCN2hAbPoR+mwG4M402Z0XVDOuV5k7n6zCRIMsgnYiyz6a61Zcw/RRHoQAb7ndqUyk8eAi9gjPEiGq2d0=", ), ), expected_statuses={200}, ), AdminApiTestCase( id="delete_public_key_credential", permission="users_edit", call=lambda api, r: api.delete_public_key_credential_with_http_info( r["user_id"], r["public_key_id"] ), expected_statuses={204}, ), AdminApiTestCase( id="get_otp_credentials", permission="users_edit", call=lambda api, r: api.get_otp_credentials_with_http_info(r["user_id"]), expected_statuses={200}, ), AdminApiTestCase( id="create_otp_credential", permission="users_edit", call=lambda api, r: api.create_otp_credential_with_http_info( r["user_id"], sdk.NewOtpCredential(name="otp-1", secret_key=[1, 2, 3]), ), expected_statuses={201}, ), AdminApiTestCase( id="delete_otp_credential", permission="users_edit", call=lambda api, r: api.delete_otp_credential_with_http_info( r["user_id"], r["otp_id"] ), expected_statuses={204}, ), AdminApiTestCase( id="get_ldap_servers", permission="config_edit", call=lambda api, r: api.get_ldap_servers_with_http_info(), expected_statuses={200}, ), AdminApiTestCase( id="create_ldap_server", permission="config_edit", call=lambda api, r: api.create_ldap_server_with_http_info( sdk.CreateLdapServerRequest( name=f"ldap-{uuid4()}", host="127.0.0.1", bind_dn="cn=admin,dc=example,dc=org", bind_password="pass", ), ), expected_statuses={201}, ), AdminApiTestCase( id="test_ldap_server_connection", permission="config_edit", call=lambda api, r: api.test_ldap_server_connection_with_http_info( sdk.TestLdapServerRequest( host="127.0.0.1", port=389, bind_dn="cn=admin,dc=example,dc=org", bind_password="pass", tls_mode=sdk.TlsMode.DISABLED, tls_verify=False, ), ), expected_statuses={200}, ), AdminApiTestCase( id="get_ldap_server", permission="config_edit", call=lambda api, r: api.get_ldap_server_with_http_info(r["ldap_server_id"]), expected_statuses={200}, ), AdminApiTestCase( id="update_ldap_server", permission="config_edit", call=lambda api, r: api.update_ldap_server_with_http_info( r["ldap_server_id"], sdk.UpdateLdapServerRequest( name=f"ldap-{uuid4()}", host="127.0.0.1", bind_dn="cn=admin,dc=example,dc=org", bind_password="pass", auto_link_sso_users=False, description="", enabled=True, port=123, ssh_key_attribute="asd", tls_mode=sdk.TlsMode.DISABLED, tls_verify=False, user_filter="(&(objectClass=person)(uid={0}))", username_attribute=sdk.LdapUsernameAttribute.UID, uuid_attribute="uid", ), ), expected_statuses={200}, ), AdminApiTestCase( id="get_ldap_users", permission="users_create", call=lambda api, r: api.get_ldap_users_with_http_info(r["ldap_server_id"]), expected_statuses={200}, ), AdminApiTestCase( id="import_ldap_users", permission="users_create", call=lambda api, r: api.import_ldap_users_with_http_info( r["ldap_server_id"], import_ldap_users_request=sdk.ImportLdapUsersRequest(dns=[]), ), expected_statuses={200}, ), AdminApiTestCase( id="get_parameters", permission=None, call=lambda api, r: api.get_parameters_with_http_info(), expected_statuses={200}, ), AdminApiTestCase( id="update_parameters", permission="config_edit", call=lambda api, r: api.update_parameters_with_http_info( sdk.ParameterUpdate( allow_own_credential_management=True, minimize_password_login=False, rate_limit_bytes_per_second=None, ssh_client_auth_keyboard_interactive=True, ssh_client_auth_password=True, ssh_client_auth_publickey=True, ), ), expected_statuses={201}, ), AdminApiTestCase( id="check_ssh_host_key", permission="targets_edit", call=lambda api, r: api.check_ssh_host_key_with_http_info( sdk.CheckSshHostKeyRequest(host="127.0.0.1", port=22), ), expected_statuses={200}, ), AdminApiTestCase( id="get_certificate_credentials", permission="users_edit", call=lambda api, r: api.get_certificate_credentials_with_http_info( r["user_id"] ), expected_statuses={200}, ), AdminApiTestCase( id="issue_certificate_credential", permission="users_edit", call=lambda api, r: api.issue_certificate_credential_with_http_info( r["user_id"], sdk.IssueCertificateCredentialRequest( label="test", public_key_pem="-----BEGIN PUBLIC KEY-----\nMFswDQYJKoZIhvcNAQEBBQADSgAwRwJAXWRPQyGlEY+SXz8Uslhe+MLjTgWd8lf/\nnA0hgCm9JFKC1tq1S73cQ9naClNXsMqY7pwPt1bSY8jYRqHHbdoUvwIDAQAB\n-----END PUBLIC KEY-----", ), ), expected_statuses={201}, ), AdminApiTestCase( id="update_certificate_credential", permission="users_edit", call=lambda api, r: api.update_certificate_credential_with_http_info( r["user_id"], r["certificate_id"], sdk.UpdateCertificateCredential(label="test"), ), expected_statuses={200}, ), AdminApiTestCase( id="revoke_certificate_credential", permission="users_edit", call=lambda api, r: api.revoke_certificate_credential_with_http_info( r["user_id"], r["certificate_id"] ), expected_statuses={204}, ), AdminApiTestCase( id="delete_role", permission="access_roles_delete", call=lambda api, r: api.delete_role_with_http_info(r["role_id"]), expected_statuses={204}, ), AdminApiTestCase( id="delete_target", permission="targets_delete", call=lambda api, r: api.delete_target_with_http_info(r["target_id"]), expected_statuses={204}, ), AdminApiTestCase( id="delete_user", permission="users_delete", call=lambda api, r: api.delete_user_with_http_info(r["user_id"]), expected_statuses={204}, ), AdminApiTestCase( id="delete_ldap_server", permission="config_edit", call=lambda api, r: api.delete_ldap_server_with_http_info(r["ldap_server_id"]), expected_statuses={204}, ), AdminApiTestCase( id="delete_admin_role", permission="admin_roles_manage", call=lambda api, r: api.delete_admin_role_with_http_info(r["admin_role_id"]), expected_statuses={204}, ), ] def _verify_all_openapi_ops_are_covered(): schema = load( open( Path(__file__).resolve().parents[1] / "warpgate-web" / "src" / "admin" / "lib" / "openapi-schema.json" ) ) schema_ops = { op.get("operationId") for methods in schema.get("paths", {}).values() for op in methods.values() if op.get("operationId") } missing = schema_ops - {c.id for c in ADMIN_API_TEST_CASES} assert not missing, f"Missing test cases for operations: {sorted(missing)}" def _create_admin_role(admin_api: sdk.DefaultApi, payload: dict) -> sdk.AdminRole: return admin_api.create_admin_role(sdk.AdminRoleDataRequest(**payload)) def _create_user_with_role(admin_api: sdk.DefaultApi, role_id: str | None): user = admin_api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) admin_api.create_password_credential( user.id, sdk.NewPasswordCredential(password="123") ) if role_id: admin_api.add_user_admin_role(user.id, role_id) return user def _create_user_api_token( base_url: str, username: str, password: str, label: str = "test" ) -> str: session = requests.Session() session.verify = False # Log in to get an authenticated session cookie. resp = session.post( f"{base_url}/@warpgate/api/auth/login", json={"username": username, "password": password}, ) resp.raise_for_status() expiry = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat() token_resp = session.post( f"{base_url}/@warpgate/api/profile/api-tokens", json={"label": label, "expiry": expiry}, ) token_resp.raise_for_status() return token_resp.json()["secret"] def test_all_openapi_admin_operations_permission_enforcement( shared_wg: WarpgateProcess, admin_client: sdk.DefaultApi ): _verify_all_openapi_ops_are_covered() url = f"https://localhost:{shared_wg.http_port}" resources: Dict[str, object] = {} resources["role_id"] = admin_client.create_role( sdk.RoleDataRequest(name=f"role-{uuid4()}") ).id resources["admin_role_id"] = _create_admin_role( admin_client, make_limited_admin_role_payload(name=f"admin-role-{uuid4()}"), ).id resources["target_group_id"] = admin_client.create_target_group( sdk.TargetGroupDataRequest( name=f"group-{uuid4()}", description="", color=sdk.BootstrapThemeColor.INFO ) ).id user = admin_client.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) resources["user_id"] = user.id resources["username"] = user.username # fake IDs here resources["session_id"] = str(uuid4()) resources["recording_id"] = str(uuid4()) resources["ssh_known_host_id"] = str(uuid4()) target = admin_client.create_target( sdk.TargetDataRequest( name=f"target-{uuid4()}", options=sdk.TargetOptions( sdk.TargetOptionsTargetSSHOptions( kind="Ssh", host="127.0.0.1", port=22, username="user", auth=sdk.SSHTargetAuth( sdk.SSHTargetAuthSshTargetPublicKeyAuth(kind="PublicKey") ), ) ), ) ) resources["target_id"] = target.id resources["target_name"] = target.name ticket = admin_client.create_ticket( sdk.CreateTicketRequest(username="test", target_name=resources["target_name"]) ) resources["ticket_id"] = ticket.ticket.id pw = admin_client.create_password_credential( resources["user_id"], sdk.NewPasswordCredential(password="123") ) resources["password_id"] = pw.id sso = admin_client.create_sso_credential( resources["user_id"], sdk.NewSsoCredential(email="test@example.com", provider="test"), ) resources["sso_id"] = sso.id public_key = admin_client.create_public_key_credential( resources["user_id"], sdk.NewPublicKeyCredential( label="key", openssh_public_key="ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKL5C+OCN2hAbPoR+mwG4M402Z0XVDOuV5k7n6zCRIMsgnYiyz6a61Zcw/RRHoQAb7ndqUyk8eAi9gjPEiGq2d0=", ), ) resources["public_key_id"] = public_key.id otp = admin_client.create_otp_credential( resources["user_id"], sdk.NewOtpCredential(name="otp-1", secret_key=[1, 2, 3]) ) resources["otp_id"] = otp.id cert = admin_client.issue_certificate_credential( resources["user_id"], sdk.IssueCertificateCredentialRequest( label="test", public_key_pem="-----BEGIN PUBLIC KEY-----\nMFswDQYJKoZIhvcNAQEBBQADSgAwRwJAXWRPQyGlEY+SXz8Uslhe+MLjTgWd8lf/\nnA0hgCm9JFKC1tq1S73cQ9naClNXsMqY7pwPt1bSY8jYRqHHbdoUvwIDAQAB\n-----END PUBLIC KEY-----", ), ) resources["certificate_id"] = cert.credential.id ldap = admin_client.create_ldap_server( sdk.CreateLdapServerRequest( name=f"ldap-{uuid4()}", host="127.0.0.1", bind_dn="cn=admin,dc=example,dc=org", bind_password="pass", ) ) resources["ldap_server_id"] = ldap.id for case in ADMIN_API_TEST_CASES: # Positive case: role has required permission (or any admin if None). allow_payload = make_limited_admin_role_payload( **({case.permission: True} if case.permission else {}) ) allowed_role = _create_admin_role(admin_client, allow_payload) allowed_user = _create_user_with_role(admin_client, allowed_role.id) token = _create_user_api_token(url, allowed_user.username, "123") with new_admin_client(url, token) as allowed_api: try: response = case.call(allowed_api, resources) (status, body) = response.status_code, response.data except sdk.ApiException as e: (status, body) = e.status, e.body assert status in case.expected_statuses, ( f"{case.id} expected {case.expected_statuses} but got {status}: {body}" ) # Negative case: permission missing should be rejected. if case.permission: denied_role = _create_admin_role( admin_client, { k: not v if isinstance(v, bool) else v for k, v in allow_payload.items() }, ) denied_user = _create_user_with_role(admin_client, denied_role.id) else: denied_user = _create_user_with_role(admin_client, None) denied_token = _create_user_api_token(url, denied_user.username, "123") with new_admin_client( f"https://localhost:{shared_wg.http_port}", denied_token ) as denied_api: try: response = case.call(denied_api, resources) (status, body) = response.status_code, response.data except sdk.ApiException as e: (status, body) = e.status, e.body assert status in {401, 403}, ( f"{case.id} should be forbidden without {case.permission}, got {status}: {body}" ) ================================================ FILE: tests/test_http_basic.py ================================================ from urllib.parse import unquote from uuid import uuid4 import requests from tests.conftest import WarpgateProcess from .api_client import admin_client, sdk from .test_http_common import * # noqa class Test: def test_basic( self, echo_server_port, shared_wg: WarpgateProcess, ): url = f"https://localhost:{shared_wg.http_port}" with admin_client(url) as api: role = api.create_role(sdk.RoleDataRequest(name=f"role-{uuid4()}")) user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) api.create_password_credential( user.id, sdk.NewPasswordCredential(password="123") ) api.add_user_role(user.id, role.id) target = api.create_target( sdk.TargetDataRequest( name=f"echo-{uuid4()}", options=sdk.TargetOptions( sdk.TargetOptionsTargetHTTPOptions( kind="Http", url=f"http://user:pass@localhost:{echo_server_port}", tls=sdk.Tls( mode=sdk.TlsMode.DISABLED, verify=False, ), ) ), ) ) api.add_target_role(target.id, role.id) session = requests.Session() session.verify = False response = session.get( f"{url}/?warpgate-target={target.name}", allow_redirects=False ) assert response.status_code == 307 redirect = response.headers["location"] print(unquote(redirect)) assert ( unquote(redirect) == f"/@warpgate#/login?next=/?warpgate-target={target.name}" ) 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.username, "password": "123", }, ) assert response.status_code == 201 response = session.get(f"{url}/@warpgate/api/info").json() assert response["username"] == user.username response = session.get( f"{url}/some/path?a=b&warpgate-target={target.name}&c=d", allow_redirects=False, ) 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" assert ['Authorization', 'Basic dXNlcjpwYXNz'] in response.json()["headers"] ================================================ FILE: tests/test_http_common.py ================================================ import gzip import pytest import threading from .util import alloc_port @pytest.fixture(scope="session") def echo_server_port(): from flask import Flask, request, jsonify, redirect, make_response 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/") def r(url): return redirect(url) @app.route("/", defaults={"path": ""}) @app.route("/") def echo(path): return jsonify( { "method": request.method, "args": request.args, "path": request.path, "headers": request.headers.to_wsgi_list(), } ) @app.route("/gzip-response") def gzip_response(): content = gzip.compress(b'response', 5) response = make_response(content) response.headers['Content-Length'] = len(content) response.headers['Content-Encoding'] = 'gzip' return response @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 ================================================ FILE: tests/test_http_proto.py ================================================ import requests from uuid import uuid4 from .api_client import admin_client, sdk from .conftest import WarpgateProcess from .test_http_common import * # noqa class TestHTTPProto: @pytest.fixture(scope="session") def setup(self, echo_server_port, shared_wg): url = f"https://localhost:{shared_wg.http_port}" with admin_client(url) as api: role = api.create_role(sdk.RoleDataRequest(name=f"role-{uuid4()}")) user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) api.create_password_credential( user.id, sdk.NewPasswordCredential(password="123") ) api.add_user_role(user.id, role.id) echo_target = api.create_target( sdk.TargetDataRequest( name=f"echo-{uuid4()}", options=sdk.TargetOptions( sdk.TargetOptionsTargetHTTPOptions( kind="Http", url=f"http://localhost:{echo_server_port}", tls=sdk.Tls( mode=sdk.TlsMode.DISABLED, verify=False, ), ) ), ) ) api.add_target_role(echo_target.id, role.id) yield url, user, echo_target def test_cookies( self, setup, shared_wg: WarpgateProcess, ): url, user, echo_target = setup session = requests.Session() session.verify = False headers = {"Host": f"localhost:{shared_wg.http_port}"} session.post( f"{url}/@warpgate/api/auth/login", json={ "username": user.username, "password": "123", }, headers=headers, ) session.get( f"{url}/set-cookie?warpgate-target={echo_target.name}", headers=headers ) cookies = session.cookies.get_dict() assert cookies["cookie"] == "value" def test_gzip( self, setup, shared_wg: WarpgateProcess, ): url, user, echo_target = setup session = requests.Session() session.verify = False headers = {"Host": f"localhost:{shared_wg.http_port}"} session.post( f"{url}/@warpgate/api/auth/login", json={ "username": user.username, "password": "123", }, headers=headers, ) response = session.get( f"{url}/gzip-response?warpgate-target={echo_target.name}", headers=headers ) assert response.text == 'response', response.text ================================================ FILE: tests/test_http_redirects.py ================================================ import requests from uuid import uuid4 from .api_client import admin_client, sdk from .conftest import WarpgateProcess from .test_http_common import * # noqa class TestHTTPRedirects: def test( self, shared_wg: WarpgateProcess, echo_server_port, ): url = f"https://localhost:{shared_wg.http_port}" with admin_client(url) as api: role = api.create_role(sdk.RoleDataRequest(name=f"role-{uuid4()}")) user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) api.create_password_credential( user.id, sdk.NewPasswordCredential(password="123") ) api.add_user_role(user.id, role.id) echo_target = api.create_target(sdk.TargetDataRequest( name=f"echo-{uuid4()}", options=sdk.TargetOptions(sdk.TargetOptionsTargetHTTPOptions( kind="Http", url=f"http://localhost:{echo_server_port}", tls=sdk.Tls( mode=sdk.TlsMode.DISABLED, verify=False, ), )), )) api.add_target_role(echo_target.id, role.id) session = requests.Session() session.verify = False headers = {"Host": f"localhost:{shared_wg.http_port}"} session.post( f"{url}/@warpgate/api/auth/login", json={ "username": user.username, "password": "123", }, headers=headers, ) response = session.get( f"{url}/redirect/http://localhost:{echo_server_port}/test?warpgate-target={echo_target.name}", headers=headers, allow_redirects=False, ) assert response.headers["location"] == "/test" ================================================ FILE: tests/test_http_user_auth_logout.py ================================================ import requests from uuid import uuid4 from .api_client import admin_client, sdk from .conftest import WarpgateProcess from .test_http_common import * # noqa class Test: def test( self, echo_server_port, shared_wg: WarpgateProcess, ): url = f"https://localhost:{shared_wg.http_port}" with admin_client(url) as api: role = api.create_role(sdk.RoleDataRequest(name=f"role-{uuid4()}")) user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) api.create_password_credential( user.id, sdk.NewPasswordCredential(password="123") ) api.add_user_role(user.id, role.id) echo_target = api.create_target(sdk.TargetDataRequest( name=f"echo-{uuid4()}", options=sdk.TargetOptions(sdk.TargetOptionsTargetHTTPOptions( kind="Http", url=f"http://localhost:{echo_server_port}", tls=sdk.Tls( mode=sdk.TlsMode.DISABLED, verify=False, ), )), )) api.add_target_role(echo_target.id, role.id) session = requests.Session() session.verify = False response = session.post( f"{url}/@warpgate/api/auth/login", json={ "username": user.username, "password": "123", }, ) assert response.status_code // 100 == 2 response = session.get( f"{url}/some/path?a=b&warpgate-target={echo_target.name}&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_target.name}", allow_redirects=False ) assert response.status_code // 100 != 2 ================================================ FILE: tests/test_http_user_auth_oidc.py ================================================ import html import re import requests from uuid import uuid4 from .api_client import admin_client, sdk from .conftest import ProcessManager from .test_http_common import * # noqa from .util import alloc_port, wait_port DEFAULT_OIDC_SCOPES = ["openid", "email", "profile", "preferred_username"] def _make_sso_provider_config( oidc_port, *, auto_create_users=False, role_mappings=None, admin_role_mappings=None, extra_scopes=None, ): """Build an ``sso_providers`` entry for warpgate config.""" scopes = list(DEFAULT_OIDC_SCOPES) if extra_scopes: scopes.extend(extra_scopes) provider = { "type": "custom", "client_id": "warpgate-test", "client_secret": "warpgate-test-secret", "issuer_url": f"http://localhost:{oidc_port}", "scopes": scopes, } if role_mappings is not None: provider["role_mappings"] = role_mappings if admin_role_mappings is not None: provider["admin_role_mappings"] = admin_role_mappings return { "name": "test-oidc", "label": "OIDC Test", "provider": provider, "auto_create_users": auto_create_users, } def _start_wg_with_oidc(processes, wg_http_port, oidc_port, **sso_kwargs): """Start a warpgate instance wired to the OIDC mock.""" sso_config = _make_sso_provider_config(oidc_port, **sso_kwargs) wg = processes.start_wg( http_port=wg_http_port, config_patch={ "external_host": "127.0.0.1", "sso_providers": [sso_config], }, ) wait_port(wg.http_port, for_process=wg.process, recv=False) return wg def _create_echo_target(api, echo_server_port, role_id): """Create an HTTP echo target and grant a role access.""" target = api.create_target( sdk.TargetDataRequest( name=f"echo-{uuid4()}", options=sdk.TargetOptions( sdk.TargetOptionsTargetHTTPOptions( kind="Http", url=f"http://localhost:{echo_server_port}", tls=sdk.Tls( mode=sdk.TlsMode.DISABLED, verify=False, ), ) ), ) ) api.add_target_role(target.id, role_id) return target def _do_oidc_login(wg_url, oidc_port, *, username="User1", password="pwd"): """Drive the full OIDC authorization-code flow against the mock. Returns ``(wg_session, redirect_url)`` where *wg_session* carries the authenticated cookies and *redirect_url* is warpgate's SSO-return URL (already followed). """ from urllib.parse import urlparse, parse_qs wg_session = requests.Session() wg_session.verify = False # Initiate SSO resp = wg_session.get(f"{wg_url}/@warpgate/api/sso/providers/test-oidc/start") assert resp.status_code == 200 auth_url = resp.json()["url"] # Follow to OIDC mock login page oidc_session = requests.Session() resp = oidc_session.get(auth_url) assert resp.status_code == 200 login_page_url = resp.url login_html = resp.text # Extract anti-forgery token (attribute order may vary) token_match = re.search( r'name="__RequestVerificationToken"[^>]*value="([^"]*)"', login_html, ) if not token_match: token_match = re.search( r'value="([^"]*)"[^>]*name="__RequestVerificationToken"', login_html, ) assert token_match, "Could not find __RequestVerificationToken in login form" verification_token = html.unescape(token_match.group(1)) # The OIDC mock may use "Input.ReturnUrl" (Duende IdentityServer # convention) or plain "ReturnUrl". Try both, then fall back to URL. return_url = None m = re.search( r'name="Input.ReturnUrl"[^>]*value="([^"]*)"', login_html, ) assert m, "Could not find ReturnUrl in login form" return_url = html.unescape(m.group(1)) # Detect whether the mock uses the "Input." field-name prefix uses_input_prefix = 'name="Input.' in login_html def _field(name): return f"Input.{name}" if uses_input_prefix else name # Submit credentials resp = oidc_session.post( login_page_url, data={ _field("Username"): username, _field("Password"): password, _field("Button") if uses_input_prefix else "button": "login", _field("ReturnUrl"): return_url, "__RequestVerificationToken": verification_token, }, allow_redirects=False, ) # Chase redirects until we land back at warpgate's SSO return endpoint redirect_url = None for _ in range(15): if resp.status_code not in (301, 302, 303, 307, 308): break location = resp.headers["Location"] if location.startswith("/"): location = f"http://localhost:{oidc_port}{location}" if "/@warpgate/api/sso/return" in location: redirect_url = location break resp = oidc_session.get(location, allow_redirects=False) assert redirect_url is not None, ( "OIDC mock did not redirect back to warpgate's SSO return endpoint" ) assert "code=" in redirect_url, "Redirect URL missing authorization code" # The OIDC redirect_uri uses 127.0.0.1 but we started the SSO flow on # wg_url (localhost). Rewrite so the session cookies (set for localhost) # are sent with this request. parsed_redirect = urlparse(redirect_url) parsed_wg = urlparse(wg_url) redirect_url = redirect_url.replace( f"{parsed_redirect.scheme}://{parsed_redirect.netloc}", f"{parsed_wg.scheme}://{parsed_wg.netloc}", 1, ) # Complete the SSO flow on warpgate resp = wg_session.get(redirect_url, allow_redirects=False) return wg_session, resp # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- class TestHTTPUserAuthOIDC: """Tests the full OIDC authorization code flow using a mock OIDC provider.""" def test_oidc_auth_flow( self, echo_server_port, processes: ProcessManager, ): wg_http_port = alloc_port() oidc_port = processes.start_oidc_server(wg_http_port) wg = _start_wg_with_oidc(processes, wg_http_port, oidc_port) wg_url = f"https://127.0.0.1:{wg.http_port}" with admin_client(wg_url) as api: role = api.create_role(sdk.RoleDataRequest(name=f"role-{uuid4()}")) user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) api.create_sso_credential( user.id, sdk.NewSsoCredential( email="sam.tailor@gmail.com", provider="test-oidc", ), ) api.add_user_role(user.id, role.id) echo_target = _create_echo_target(api, echo_server_port, role.id) # Verify SSO provider is listed wg_session = requests.Session() wg_session.verify = False resp = wg_session.get(f"{wg_url}/@warpgate/api/sso/providers") assert resp.status_code == 200 assert any(p["name"] == "test-oidc" for p in resp.json()) wg_session, resp = _do_oidc_login(wg_url, oidc_port) assert resp.status_code in (302, 307), ( f"Expected redirect from SSO return, got {resp.status_code}: {resp.text[:500]}" ) # Verify authenticated access to the echo target resp = wg_session.get( f"{wg_url}/some/path?a=b&warpgate-target={echo_target.name}&c=d", allow_redirects=False, ) assert resp.status_code // 100 == 2 assert resp.json()["path"] == "/some/path" def test_oidc_auth_wrong_credentials( self, echo_server_port, processes: ProcessManager, ): wg_http_port = alloc_port() oidc_port = processes.start_oidc_server(wg_http_port) wg = _start_wg_with_oidc(processes, wg_http_port, oidc_port) wg_url = f"https://127.0.0.1:{wg.http_port}" with admin_client(wg_url) as api: role = api.create_role(sdk.RoleDataRequest(name=f"role-{uuid4()}")) user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) api.create_sso_credential( user.id, sdk.NewSsoCredential( email="wrong-email@example.com", provider="test-oidc", ), ) api.add_user_role(user.id, role.id) echo_target = _create_echo_target(api, echo_server_port, role.id) # Login with valid OIDC creds, but the mock user's email # (sam.tailor@gmail.com) doesn't match the SSO credential. wg_session, resp = _do_oidc_login(wg_url, oidc_port) assert resp.status_code in (302, 307) location = resp.headers.get("Location", "") assert "login_error" in location # Verify user is NOT authenticated resp = wg_session.get( f"{wg_url}/some/path?warpgate-target={echo_target.name}", allow_redirects=False, ) assert resp.status_code // 100 != 2 # -- User autocreation tests ------------------------------------------- def test_oidc_auto_create_user( self, echo_server_port, processes: ProcessManager, ): wg_http_port = alloc_port() oidc_port = processes.start_oidc_server(wg_http_port) wg = _start_wg_with_oidc( processes, wg_http_port, oidc_port, auto_create_users=True ) wg_url = f"https://127.0.0.1:{wg.http_port}" # Pre-create a role and target so the auto-created user can be granted # access after creation (we assign the role to the target but NOT to # any user yet). with admin_client(wg_url) as api: role = api.create_role(sdk.RoleDataRequest(name=f"role-{uuid4()}")) _create_echo_target(api, echo_server_port, role.id) # No users exist with this SSO credential yet users_before = api.get_users() wg_session, resp = _do_oidc_login(wg_url, oidc_port) assert resp.status_code in (302, 307), ( f"Expected redirect after auto-creation, got {resp.status_code}" ) # Verify the user was created with the preferred_username from OIDC with admin_client(wg_url) as api: users_after = api.get_users() new_users = [ u for u in users_after if u.username not in {ub.username for ub in users_before} ] assert len(new_users) == 1, ( f"Expected exactly 1 new user, found {len(new_users)}" ) assert new_users[0].username == "sam_tailor" def test_oidc_auto_create_user_disabled( self, echo_server_port, processes: ProcessManager, ): """When auto_create_users is False (the default) and no matching SSO credential exists, the login should be rejected.""" wg_http_port = alloc_port() oidc_port = processes.start_oidc_server(wg_http_port) wg = _start_wg_with_oidc( processes, wg_http_port, oidc_port, auto_create_users=False ) wg_url = f"https://127.0.0.1:{wg.http_port}" # Don't create any user/SSO credential - login should fail wg_session, resp = _do_oidc_login(wg_url, oidc_port) assert resp.status_code in (302, 307) location = resp.headers.get("Location", "") assert "login_error" in location # -- Group sync / role mapping tests ----------------------------------- def test_oidc_group_sync_with_role_mappings( self, echo_server_port, processes: ProcessManager, ): wg_http_port = alloc_port() # Configure mock user with warpgate_roles claims oidc_users = [ { "SubjectId": "1", "Username": "User1", "Password": "pwd", "Claims": [ {"Type": "name", "Value": "Sam Tailor", "ValueType": "string"}, { "Type": "email", "Value": "sam.tailor@gmail.com", "ValueType": "string", }, { "Type": "preferred_username", "Value": "sam_tailor", "ValueType": "string", }, # The OIDC mock provides multiple claims with the same type # for array values in userinfo. { "Type": "warpgate_roles", "Value": "oidc-admins", "ValueType": "string", }, { "Type": "warpgate_roles", "Value": "oidc-viewers", "ValueType": "string", }, ], } ] oidc_port = processes.start_oidc_server( wg_http_port, extra_scopes=["warpgate_roles"], users_override=oidc_users, extra_identity_resources=[ {"Name": "warpgate_roles", "ClaimTypes": ["warpgate_roles"]}, ], ) # Map OIDC groups to warpgate role names role_mappings = { "oidc-admins": "wg-admin-role", "oidc-viewers": "wg-viewer-role", } wg = _start_wg_with_oidc( processes, wg_http_port, oidc_port, auto_create_users=True, role_mappings=role_mappings, extra_scopes=["warpgate_roles"], ) wg_url = f"https://127.0.0.1:{wg.http_port}" with admin_client(wg_url) as api: # Pre-create the roles that the mapping targets admin_role = api.create_role(sdk.RoleDataRequest(name="wg-admin-role")) api.create_role(sdk.RoleDataRequest(name="wg-viewer-role")) # Also create a role that is NOT in the mapping (should not be # assigned) api.create_role(sdk.RoleDataRequest(name="wg-unrelated-role")) _create_echo_target(api, echo_server_port, admin_role.id) wg_session, resp = _do_oidc_login(wg_url, oidc_port) assert resp.status_code in (302, 307) # Verify the auto-created user has the mapped roles with admin_client(wg_url) as api: users = api.get_users() user = next(u for u in users if u.username == "sam_tailor") user_roles = api.get_user_roles(user.id) role_names = {r.name for r in user_roles} assert "wg-admin-role" in role_names assert "wg-viewer-role" in role_names assert "wg-unrelated-role" not in role_names # verify no admin roles yet admin_roles = api.get_user_admin_roles(user.id) assert admin_roles == [] def test_oidc_group_sync_without_mappings( self, echo_server_port, processes: ProcessManager, ): """When no role_mappings are configured, warpgate_roles claim values are used directly as role names.""" wg_http_port = alloc_port() oidc_users = [ { "SubjectId": "1", "Username": "User1", "Password": "pwd", "Claims": [ {"Type": "name", "Value": "Sam Tailor", "ValueType": "string"}, { "Type": "email", "Value": "sam.tailor@gmail.com", "ValueType": "string", }, { "Type": "preferred_username", "Value": "sam_tailor", "ValueType": "string", }, { "Type": "warpgate_roles", "Value": "direct-role-a", "ValueType": "string", }, { "Type": "warpgate_roles", "Value": "direct-role-b", "ValueType": "string", }, ], } ] oidc_port = processes.start_oidc_server( wg_http_port, extra_scopes=["warpgate_roles"], users_override=oidc_users, extra_identity_resources=[ {"Name": "warpgate_roles", "ClaimTypes": ["warpgate_roles"]}, ], ) wg = _start_wg_with_oidc( processes, wg_http_port, oidc_port, auto_create_users=True, extra_scopes=["warpgate_roles"], ) wg_url = f"https://127.0.0.1:{wg.http_port}" with admin_client(wg_url) as api: role_a = api.create_role(sdk.RoleDataRequest(name="direct-role-a")) api.create_role(sdk.RoleDataRequest(name="direct-role-b")) _create_echo_target(api, echo_server_port, role_a.id) wg_session, resp = _do_oidc_login(wg_url, oidc_port) assert resp.status_code in (302, 307) with admin_client(wg_url) as api: users = api.get_users() user = next(u for u in users if u.username == "sam_tailor") user_roles = api.get_user_roles(user.id) role_names = {r.name for r in user_roles} assert "direct-role-a" in role_names assert "direct-role-b" in role_names # admin role import without mappings admin_roles = api.get_user_admin_roles(user.id) assert admin_roles == [] def test_oidc_group_sync_removes_stale_roles( self, echo_server_port, processes: ProcessManager, ): """On subsequent logins, roles that are no longer present in the OIDC warpgate_roles claim should be removed.""" wg_http_port = alloc_port() # First login: user has both roles oidc_users_both = [ { "SubjectId": "1", "Username": "User1", "Password": "pwd", "Claims": [ {"Type": "name", "Value": "Sam Tailor", "ValueType": "string"}, { "Type": "email", "Value": "sam.tailor@gmail.com", "ValueType": "string", }, { "Type": "preferred_username", "Value": "sam_tailor", "ValueType": "string", }, { "Type": "warpgate_roles", "Value": "role-keep", "ValueType": "string", }, { "Type": "warpgate_roles", "Value": "role-remove", "ValueType": "string", }, ], } ] oidc_port = processes.start_oidc_server( wg_http_port, extra_scopes=["warpgate_roles"], users_override=oidc_users_both, extra_identity_resources=[ {"Name": "warpgate_roles", "ClaimTypes": ["warpgate_roles"]}, ], ) wg = _start_wg_with_oidc( processes, wg_http_port, oidc_port, auto_create_users=True, extra_scopes=["warpgate_roles"], ) wg_url = f"https://127.0.0.1:{wg.http_port}" with admin_client(wg_url) as api: api.create_role(sdk.RoleDataRequest(name="role-keep")) role_rm = api.create_role(sdk.RoleDataRequest(name="role-remove")) _create_echo_target(api, echo_server_port, role_rm.id) # First login — user gets both roles _, resp = _do_oidc_login(wg_url, oidc_port) assert resp.status_code in (302, 307) with admin_client(wg_url) as api: users = api.get_users() user = next(u for u in users if u.username == "sam_tailor") role_names = {r.name for r in api.get_user_roles(user.id)} assert "role-keep" in role_names assert "role-remove" in role_names # Second login: start a new OIDC mock where the user only has # "role-keep". We need a fresh mock because the mock server's user # config is fixed at startup. wg_http_port2 = alloc_port() oidc_users_reduced = [ { "SubjectId": "1", "Username": "User1", "Password": "pwd", "Claims": [ {"Type": "name", "Value": "Sam Tailor", "ValueType": "string"}, { "Type": "email", "Value": "sam.tailor@gmail.com", "ValueType": "string", }, { "Type": "preferred_username", "Value": "sam_tailor", "ValueType": "string", }, { "Type": "warpgate_roles", "Value": "role-keep", "ValueType": "string", }, ], } ] oidc_port2 = processes.start_oidc_server( wg_http_port2, extra_scopes=["warpgate_roles"], users_override=oidc_users_reduced, extra_identity_resources=[ {"Name": "warpgate_roles", "ClaimTypes": ["warpgate_roles"]}, ], ) wg2 = _start_wg_with_oidc( processes, wg_http_port2, oidc_port2, auto_create_users=True, extra_scopes=["warpgate_roles"], ) wg_url2 = f"https://127.0.0.1:{wg2.http_port}" # The user already exists from the first wg instance's DB, but this is # a fresh warpgate with its own DB. Re-create the user so we can test # the role-removal path. with admin_client(wg_url2) as api: api.create_role(sdk.RoleDataRequest(name="role-keep")) api.create_role(sdk.RoleDataRequest(name="role-remove")) user = api.create_user(sdk.CreateUserRequest(username="sam_tailor")) api.create_sso_credential( user.id, sdk.NewSsoCredential( email="sam.tailor@gmail.com", provider="test-oidc", ), ) # Pre-assign both roles roles = api.get_roles() for r in roles: if r.name in ("role-keep", "role-remove"): api.add_user_role(user.id, r.id) _, resp = _do_oidc_login(wg_url2, oidc_port2) assert resp.status_code in (302, 307) with admin_client(wg_url2) as api: user_roles = api.get_user_roles(user.id) role_names = {r.name for r in user_roles} assert "role-keep" in role_names assert "role-remove" not in role_names ================================================ FILE: tests/test_http_user_auth_otp.py ================================================ import requests import pyotp from base64 import b64decode from uuid import uuid4 from .api_client import admin_client, sdk from .conftest import WarpgateProcess from .test_http_common import * # noqa class TestHTTPUserAuthOTP: def test_auth_otp_success( self, otp_key_base32, otp_key_base64, echo_server_port, shared_wg: WarpgateProcess, ): url = f"https://localhost:{shared_wg.http_port}" with admin_client(url) as api: role = api.create_role(sdk.RoleDataRequest(name=f"role-{uuid4()}")) user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) api.create_password_credential( user.id, sdk.NewPasswordCredential(password="123") ) api.create_otp_credential( user.id, sdk.NewOtpCredential(secret_key=list(b64decode(otp_key_base64))), ) api.update_user( user.id, sdk.UserDataRequest( username=user.username, credential_policy=sdk.UserRequireCredentialsPolicy( http=["Password", "Totp"] ), ), ) api.add_user_role(user.id, role.id) echo_target = api.create_target( sdk.TargetDataRequest( name=f"echo-{uuid4()}", options=sdk.TargetOptions( sdk.TargetOptionsTargetHTTPOptions( kind="Http", url=f"http://localhost:{echo_server_port}", tls=sdk.Tls( mode=sdk.TlsMode.DISABLED, verify=False, ), ) ), ) ) api.add_target_role(echo_target.id, role.id) session = requests.Session() session.verify = False totp = pyotp.TOTP(otp_key_base32) response = session.post( f"{url}/@warpgate/api/auth/login", json={ "username": user.username, "password": "123", }, ) assert response.status_code // 100 != 2 response = session.get( f"{url}/some/path?a=b&warpgate-target={echo_target.name}&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_target.name}&c=d", allow_redirects=False, ) assert response.status_code // 100 == 2 assert response.json()["path"] == "/some/path" def test_auth_otp_fail( self, otp_key_base64, echo_server_port, shared_wg: WarpgateProcess, ): url = f"https://localhost:{shared_wg.http_port}" with admin_client(url) as api: role = api.create_role(sdk.RoleDataRequest(name=f"role-{uuid4()}")) user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) api.create_password_credential( user.id, sdk.NewPasswordCredential(password="123") ) api.create_otp_credential( user.id, sdk.NewOtpCredential(secret_key=list(b64decode(otp_key_base64))), ) api.update_user( user.id, sdk.UserDataRequest( username=user.username, credential_policy=sdk.UserRequireCredentialsPolicy( http=["Password", "Totp"] ), ), ) api.add_user_role(user.id, role.id) echo_target = api.create_target( sdk.TargetDataRequest( name=f"echo-{uuid4()}", options=sdk.TargetOptions( sdk.TargetOptionsTargetHTTPOptions( kind="Http", url=f"http://localhost:{echo_server_port}", tls=sdk.Tls( mode=sdk.TlsMode.DISABLED, verify=False, ), ) ), ) ) api.add_target_role(echo_target.id, role.id) session = requests.Session() session.verify = False response = session.post( f"{url}/@warpgate/api/auth/login", json={ "username": user.username, "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_target.name}&c=d", allow_redirects=False, ) assert response.status_code // 100 != 2 ================================================ FILE: tests/test_http_user_auth_password.py ================================================ import requests from uuid import uuid4 from .api_client import admin_client, sdk from .conftest import WarpgateProcess from .test_http_common import * # noqa class TestHTTPUserAuthPassword: def test_auth_password_success( self, echo_server_port, shared_wg: WarpgateProcess, ): url = f"https://localhost:{shared_wg.http_port}" with admin_client(url) as api: role = api.create_role(sdk.RoleDataRequest(name=f"role-{uuid4()}")) user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) api.create_password_credential( user.id, sdk.NewPasswordCredential(password="123") ) api.add_user_role(user.id, role.id) echo_target = api.create_target( sdk.TargetDataRequest( name=f"echo-{uuid4()}", options=sdk.TargetOptions( sdk.TargetOptionsTargetHTTPOptions( kind="Http", url=f"http://localhost:{echo_server_port}", tls=sdk.Tls( mode=sdk.TlsMode.DISABLED, verify=False, ), ) ), ) ) api.add_target_role(echo_target.id, role.id) session = requests.Session() session.verify = False response = session.get( f"{url}/?warpgate-target={echo_target.name}", allow_redirects=False ) assert response.status_code // 100 != 2 response = session.post( f"{url}/@warpgate/api/auth/login", json={ "username": user.username, "password": "123", }, ) assert response.status_code // 100 == 2 response = session.get( f"{url}/some/path?a=b&warpgate-target={echo_target.name}&c=d", allow_redirects=False, ) assert response.status_code // 100 == 2 assert response.json()["path"] == "/some/path" def test_auth_password_fail( self, echo_server_port, shared_wg: WarpgateProcess, ): url = f"https://localhost:{shared_wg.http_port}" with admin_client(url) as api: role = api.create_role(sdk.RoleDataRequest(name=f"role-{uuid4()}")) user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) api.create_password_credential( user.id, sdk.NewPasswordCredential(password="123") ) api.add_user_role(user.id, role.id) echo_target = api.create_target( sdk.TargetDataRequest( name=f"echo-{uuid4()}", options=sdk.TargetOptions( sdk.TargetOptionsTargetHTTPOptions( kind="Http", url=f"http://localhost:{echo_server_port}", tls=sdk.Tls( mode=sdk.TlsMode.DISABLED, verify=False, ), ) ), ) ) api.add_target_role(echo_target.id, role.id) session = requests.Session() session.verify = False response = session.post( f"{url}/@warpgate/api/auth/login", json={ "username": user.username, "password": "321321", }, ) assert response.status_code // 100 != 2 response = session.get( f"{url}/some/path?a=b&warpgate-target={echo_target.name}&c=d", allow_redirects=False, ) assert response.status_code // 100 != 2 ================================================ FILE: tests/test_http_user_auth_ticket.py ================================================ import requests from uuid import uuid4 from .api_client import admin_client, sdk from .conftest import WarpgateProcess from .test_http_common import * # noqa class TestHTTPUserAuthTicket: def test_auth_password_success( self, echo_server_port, shared_wg: WarpgateProcess, ): url = f"https://localhost:{shared_wg.http_port}" with admin_client(url) as api: role = api.create_role(sdk.RoleDataRequest(name=f"role-{uuid4()}")) user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) api.create_password_credential( user.id, sdk.NewPasswordCredential(password="123") ) api.add_user_role(user.id, role.id) echo_target = api.create_target(sdk.TargetDataRequest( name=f"echo-{uuid4()}", options=sdk.TargetOptions(sdk.TargetOptionsTargetHTTPOptions( kind="Http", url=f"http://localhost:{echo_server_port}", tls=sdk.Tls( mode=sdk.TlsMode.DISABLED, verify=False, ), )), )) api.add_target_role(echo_target.id, role.id) other_target = api.create_target( sdk.TargetDataRequest( name=f"other-{uuid4()}", options=sdk.TargetOptions( sdk.TargetOptionsTargetHTTPOptions( kind="Http", url="http://badhost", tls=sdk.Tls( mode=sdk.TlsMode.DISABLED, verify=False, ), ) ), ) ) api.add_target_role(other_target.id, role.id) secret = api.create_ticket(sdk.CreateTicketRequest( target_name=echo_target.name, username=user.username, )).secret # --- session = requests.Session() session.verify = False response = session.get( f"{url}/some/path?warpgate-target={echo_target.name}", allow_redirects=False, ) assert response.status_code // 100 != 2 # Ticket as a header response = session.get( f"{url}/some/path?warpgate-target={echo_target.name}", allow_redirects=False, headers={ "Authorization": f"Warpgate {secret}", }, ) assert response.status_code // 100 == 2 assert response.json()["path"] == "/some/path" # Bad ticket response = session.get( f"{url}/some/path?warpgate-target={echo_target.name}", allow_redirects=False, headers={ "Authorization": f"Warpgate bad{secret}", }, ) assert response.status_code // 100 != 2 # 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" response = session.get( f"{url}/some/path?warpgate-ticket={secret}&warpgate-target={other_target.name}", allow_redirects=False, ) assert response.status_code // 100 == 2 assert response.json()["path"] == "/some/path" ================================================ FILE: tests/test_http_websocket.py ================================================ import ssl import requests from websocket import create_connection from uuid import uuid4 from .api_client import admin_client, sdk from .conftest import WarpgateProcess from .test_http_common import * # noqa class TestHTTPWebsocket: def test_basic( self, echo_server_port, shared_wg: WarpgateProcess, ): url = f"https://localhost:{shared_wg.http_port}" with admin_client(url) as api: role = api.create_role(sdk.RoleDataRequest(name=f"role-{uuid4()}")) user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) api.create_password_credential( user.id, sdk.NewPasswordCredential(password="123") ) api.add_user_role(user.id, role.id) echo_target = api.create_target(sdk.TargetDataRequest( name=f"echo-{uuid4()}", options=sdk.TargetOptions(sdk.TargetOptionsTargetHTTPOptions( kind="Http", url=f"http://localhost:{echo_server_port}", tls=sdk.Tls( mode=sdk.TlsMode.DISABLED, verify=False, ), )), )) api.add_target_role(echo_target.id, role.id) session = requests.Session() session.verify = False session.post( f"{url}/@warpgate/api/auth/login", json={ "username": user.username, "password": "123", }, ) cookies = session.cookies.get_dict() cookie = "; ".join([f"{k}={v}" for k, v in cookies.items()]) ws = create_connection( f"wss://localhost:{shared_wg.http_port}/socket?warpgate-target={echo_target.name}", 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() ================================================ FILE: tests/test_json_logs.py ================================================ """ Integration tests for JSON log output format. Tests that log.format: json configuration produces valid JSON logs. """ import json import subprocess import tempfile import time from pathlib import Path import requests import yaml from .conftest import ProcessManager from .util import wait_port class Test: """Test JSON log output format.""" def test_json_logs_via_config( self, processes: ProcessManager, timeout, ): """Test that log.format: json in config produces JSON output.""" # Create a temporary file to capture log output with tempfile.NamedTemporaryFile(mode='w', suffix='.log', delete=False) as log_file: log_output_path = Path(log_file.name) try: # Start Warpgate to do initial setup (this creates config) wg = processes.start_wg() wait_port(wg.http_port, for_process=wg.process, recv=False, timeout=timeout) # Stop the process so we can modify the config wg.process.terminate() try: wg.process.wait(timeout=5) except subprocess.TimeoutExpired: wg.process.kill() wg.process.wait() # Modify config to enable JSON logs config = yaml.safe_load(wg.config_path.open()) config["log"] = config.get("log", {}) config["log"]["format"] = "json" with wg.config_path.open("w") as f: yaml.safe_dump(config, f) # Restart Warpgate with JSON log config, capturing stdout with open(log_output_path, "w") as log_capture: wg_json = processes.start_wg( share_with=wg, args=["run", "--enable-admin-token"], stdout=log_capture, stderr=subprocess.STDOUT, ) # Wait for Warpgate to start wait_port(wg_json.http_port, for_process=wg_json.process, recv=False, timeout=timeout) # Give it a moment to log startup messages time.sleep(1) # Make a request to generate more logs session = requests.Session() session.verify = False try: session.get(f"https://localhost:{wg_json.http_port}/", timeout=5) except Exception: pass # Expected to fail without proper auth, but will generate logs # Give it a moment to write logs time.sleep(0.5) # Read and validate log output log_content = log_output_path.read_text() lines = [line.strip() for line in log_content.split("\n") if line.strip()] assert len(lines) > 0, "No log output captured" # Validate each line is valid JSON json_entries = [] for i, line in enumerate(lines): try: entry = json.loads(line) json_entries.append(entry) except json.JSONDecodeError as e: raise AssertionError(f"Line {i+1} is not valid JSON: {line[:100]}... Error: {e}") # Validate structure of at least one entry assert len(json_entries) > 0, "No JSON log entries found" # Check that entries have required fields for entry in json_entries: assert "timestamp" in entry, f"Missing 'timestamp' field in: {entry}" assert "level" in entry, f"Missing 'level' field in: {entry}" assert "target" in entry, f"Missing 'target' field in: {entry}" assert "message" in entry, f"Missing 'message' field in: {entry}" # Validate timestamp format (ISO 8601) assert "T" in entry["timestamp"], f"Invalid timestamp format: {entry['timestamp']}" # Validate level is lowercase assert entry["level"] in ["trace", "debug", "info", "warn", "error"], \ f"Invalid level: {entry['level']}" finally: # Clean up temp file log_output_path.unlink(missing_ok=True) def test_json_logs_via_cli( self, processes: ProcessManager, timeout, ): """Test that --log-format json CLI flag produces JSON output.""" # Create a temporary file to capture log output with tempfile.NamedTemporaryFile(mode='w', suffix='.log', delete=False) as log_file: log_output_path = Path(log_file.name) try: # Start Warpgate to do initial setup (this creates config) # We need to do setup first without capturing stdout, because # start_wg passes stdout to both setup and run phases wg = processes.start_wg() wait_port(wg.http_port, for_process=wg.process, recv=False, timeout=timeout) # Stop the process so we can restart with CLI flag wg.process.terminate() try: wg.process.wait(timeout=5) except subprocess.TimeoutExpired: wg.process.kill() wg.process.wait() # Restart Warpgate with --log-format json CLI flag, capturing stdout with open(log_output_path, "w") as log_capture: wg_json = processes.start_wg( share_with=wg, args=["--log-format", "json", "run", "--enable-admin-token"], stdout=log_capture, stderr=subprocess.STDOUT, ) # Wait for Warpgate to start wait_port(wg_json.http_port, for_process=wg_json.process, recv=False, timeout=timeout) # Give it a moment to log startup messages time.sleep(1) # Read and validate log output log_content = log_output_path.read_text() lines = [line.strip() for line in log_content.split("\n") if line.strip()] assert len(lines) > 0, "No log output captured" # Validate at least first line is valid JSON with required fields first_line = lines[0] try: entry = json.loads(first_line) assert "timestamp" in entry, f"Missing 'timestamp' field" assert "level" in entry, f"Missing 'level' field" assert "target" in entry, f"Missing 'target' field" assert "message" in entry, f"Missing 'message' field" except json.JSONDecodeError as e: raise AssertionError(f"First line is not valid JSON: {first_line[:100]}... Error: {e}") finally: # Clean up temp file log_output_path.unlink(missing_ok=True) ================================================ FILE: tests/test_kubernetes_integration.py ================================================ from datetime import datetime, timezone, timedelta import time import uuid import subprocess from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa import aiohttp import pytest from .api_client import admin_client, sdk from .conftest import WarpgateProcess, K3sInstance def run_kubectl(args, **kwargs): return subprocess.run( args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs ) class TestKubernetesIntegration: @pytest.mark.asyncio async def test_kubectl_through_warpgate( self, processes, shared_wg: WarpgateProcess ): # start k3s and obtain a service-account token k3s = processes.start_k3s() k3s_port = k3s.port k3s_token = k3s.token url = f"https://localhost:{shared_wg.http_port}" # create user/role and give them a password with admin_client(url) as api: role = api.create_role(sdk.RoleDataRequest(name=f"role-{uuid.uuid4()}")) user = api.create_user( sdk.CreateUserRequest(username=f"user-{uuid.uuid4()}") ) api.create_password_credential( user.id, sdk.NewPasswordCredential(password="123") ) api.add_user_role(user.id, role.id) # generate a keypair for certificate auth using cryptography (avoid # depending on openssl binary in the container) key = rsa.generate_private_key(public_exponent=65537, key_size=2048) key_pem = key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.TraditionalOpenSSL, encryption_algorithm=serialization.NoEncryption(), ).decode() pub_pem = ( key.public_key() .public_bytes( encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo, ) .decode() ) # write the private key so that other helpers (if any) could inspect it key_path = processes.ctx.tmpdir / f"k8s-key-{uuid.uuid4()}.pem" key_path.write_text(key_pem) # issue certificate credential for the user with admin_client(url) as api: issued = api.issue_certificate_credential( user.id, sdk.IssueCertificateCredentialRequest( label="kubectl-cert", public_key_pem=pub_pem, ), ) cert_pem = issued.certificate_pem # create a single token-based Kubernetes target (Warpgate→k8s auth) token_target_name = f"k8s-token-{uuid.uuid4()}" with admin_client(url) as api: token_target = api.create_target( sdk.TargetDataRequest( name=token_target_name, options=sdk.TargetOptions( sdk.TargetOptionsTargetKubernetesOptions( kind="Kubernetes", cluster_url=f"https://127.0.0.1:{k3s_port}", tls=sdk.Tls(mode=sdk.TlsMode.PREFERRED, verify=False), auth=sdk.KubernetesTargetAuth( sdk.KubernetesTargetAuthKubernetesTargetTokenAuth( kind="Token", token=k3s_token ) ), ) ), ) ) api.add_target_role(token_target.id, role.id) # login and obtain a user API token async with aiohttp.ClientSession() as session: headers = {"Host": f"localhost:{shared_wg.http_port}"} resp = await session.post( f"{url}/@warpgate/api/auth/login", json={"username": user.username, "password": "123"}, headers=headers, ssl=False, ) resp.raise_for_status() resp = await session.post( f"{url}/@warpgate/api/profile/api-tokens", json={ "label": "test-token", "expiry": ( datetime.now(timezone.utc) + timedelta(days=1) ).isoformat(), }, ssl=False, ) resp.raise_for_status() user_token = (await resp.json())["secret"] # positive token auth (cluster side uses token always) server = f"https://127.0.0.1:{shared_wg.kubernetes_port}/{token_target_name}" token_cmd = [ "kubectl", "get", "pods", "--server", server, "--insecure-skip-tls-verify", "--token", user_token, "-n", "default", ] p = run_kubectl(token_cmd) assert p.returncode == 0, f"should accept the correct token: {p.stderr!r}" # negative token case bad_cmd = token_cmd.copy() bad_cmd[bad_cmd.index(user_token)] = user_token + "x" p = run_kubectl(bad_cmd) assert p.returncode != 0, "should not accept an invalid token" # positive client-certificate auth to Warpgate (user→wg) cert_file = processes.ctx.tmpdir / f"k8s-cert-{uuid.uuid4()}.pem" key_file = processes.ctx.tmpdir / f"k8s-key-{uuid.uuid4()}.pem" cert_file.write_text(cert_pem) key_file.write_text(key_path.read_text()) # minimal kubeconfig again to avoid side-effects kubeconf = processes.ctx.tmpdir / f"kubeconfig-{uuid.uuid4()}.yaml" kubeconf.write_text("apiVersion: v1\nkind: Config\n") cert_cmd = [ "kubectl", "--kubeconfig", str(kubeconf), "get", "pods", "--server", server, "--insecure-skip-tls-verify", "--client-certificate", str(cert_file), "--client-key", str(key_file), "-n", "default", ] p = run_kubectl(cert_cmd) assert p.returncode == 0, f"should accept the valid certificate: {p.stderr!r}" # negative cert to Warpgate (wrong key) wrong_key = processes.ctx.tmpdir / f"k8s-wrong-{uuid.uuid4()}.pem" # generate an unrelated key locally with cryptography wrong_rsa = rsa.generate_private_key(public_exponent=65537, key_size=2048) wrong_pem = wrong_rsa.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.TraditionalOpenSSL, encryption_algorithm=serialization.NoEncryption(), ).decode() wrong_key.write_text(wrong_pem) bad_cert_cmd = cert_cmd.copy() bad_cert_cmd[bad_cert_cmd.index(str(key_file))] = str(wrong_key) p = run_kubectl(bad_cert_cmd) assert p.returncode != 0, "should not accept an unknown certificate" @pytest.mark.asyncio async def test_kubectl_run(self, processes, shared_wg: WarpgateProcess): """Ensure that write requests such as ``kubectl run`` are proxied.""" k3s: K3sInstance = processes.start_k3s() k3s_port = k3s.port k3s_token = k3s.token url = f"https://localhost:{shared_wg.http_port}" # create user/role as before with admin_client(url) as api: role = api.create_role(sdk.RoleDataRequest(name=f"role-{uuid.uuid4()}")) user = api.create_user( sdk.CreateUserRequest(username=f"user-{uuid.uuid4()}") ) api.create_password_credential( user.id, sdk.NewPasswordCredential(password="123") ) api.add_user_role(user.id, role.id) target_name = f"k8s-run-{uuid.uuid4()}" with admin_client(url) as api: target = api.create_target( sdk.TargetDataRequest( name=target_name, options=sdk.TargetOptions( sdk.TargetOptionsTargetKubernetesOptions( kind="Kubernetes", cluster_url=f"https://127.0.0.1:{k3s_port}", tls=sdk.Tls(mode=sdk.TlsMode.PREFERRED, verify=False), auth=sdk.KubernetesTargetAuth( sdk.KubernetesTargetAuthKubernetesTargetTokenAuth( kind="Token", token=k3s_token ) ), ) ), ) ) api.add_target_role(target.id, role.id) # login and request api token async with aiohttp.ClientSession() as session: resp = await session.post( f"{url}/@warpgate/api/auth/login", json={"username": user.username, "password": "123"}, ssl=False, ) resp.raise_for_status() resp = await session.post( f"{url}/@warpgate/api/profile/api-tokens", json={ "label": "run-token", "expiry": ( datetime.now(timezone.utc) + timedelta(days=1) ).isoformat(), }, ssl=False, ) resp.raise_for_status() user_token = (await resp.json())["secret"] server = f"https://127.0.0.1:{shared_wg.kubernetes_port}/{target_name}" # use interactive tty and echo some data through cat to ensure # stdin/stdout forwarding works for ``kubectl run`` as well run_cmd = [ "kubectl", "run", "-v9", "--server", server, "--insecure-skip-tls-verify", "--token", user_token, "-n", "default", "test-cat", "--image=alpine:3", "--restart=Never", "-i", "--rm", "--command", "--", "cat", ] p = run_kubectl( run_cmd, input=b"hello-from-run\n", timeout=120, ) assert p.returncode == 0, f"kubectl run should succeed: {p.stderr!r}" assert b"hello-from-run" in p.stdout, ( f"run stdout did not contain expected text: {p.stdout!r}" ) @pytest.mark.asyncio async def test_mtls_upstream_and_token_user( self, processes, shared_wg: WarpgateProcess ): # k3s already running above? start another instance for isolation k3s: K3sInstance = processes.start_k3s() k3s_port = k3s.port # client cert/key for warpgate->k8s mtls_cert = k3s.client_cert mtls_key = k3s.client_key # sanity‑check the values we got from start_k3s -- they must look like PEM assert mtls_cert and "BEGIN CERTIFICATE" in mtls_cert, ( "upstream cert missing or invalid" ) assert mtls_key and "BEGIN" in mtls_key, "upstream key missing or invalid" url = f"https://localhost:{shared_wg.http_port}" with admin_client(url) as api: role = api.create_role(sdk.RoleDataRequest(name=f"role-{uuid.uuid4()}")) user = api.create_user( sdk.CreateUserRequest(username=f"user-{uuid.uuid4()}") ) api.create_password_credential( user.id, sdk.NewPasswordCredential(password="123") ) api.add_user_role(user.id, role.id) # create certificate-auth target (Warpgate->k8s) token_target_name = f"k8s-mtls-{uuid.uuid4()}" with admin_client(url) as api: target = api.create_target( sdk.TargetDataRequest( name=token_target_name, options=sdk.TargetOptions( sdk.TargetOptionsTargetKubernetesOptions( kind="Kubernetes", cluster_url=f"https://127.0.0.1:{k3s_port}", tls=sdk.Tls(mode=sdk.TlsMode.PREFERRED, verify=False), auth=sdk.KubernetesTargetAuth( sdk.KubernetesTargetAuthKubernetesTargetCertificateAuth( kind="Certificate", certificate=mtls_cert, private_key=mtls_key, ) ), ) ), ) ) api.add_target_role(target.id, role.id) # login user and create token async with aiohttp.ClientSession() as session: headers = {"Host": f"localhost:{shared_wg.http_port}"} resp = await session.post( f"{url}/@warpgate/api/auth/login", json={"username": user.username, "password": "123"}, headers=headers, ssl=False, ) resp.raise_for_status() resp = await session.post( f"{url}/@warpgate/api/profile/api-tokens", json={ "label": "test-token", "expiry": ( datetime.now(timezone.utc) + timedelta(days=1) ).isoformat(), }, ssl=False, ) resp.raise_for_status() user_token = (await resp.json())["secret"] server = f"https://127.0.0.1:{shared_wg.kubernetes_port}/{token_target_name}" kubeconf = processes.ctx.tmpdir / f"kubeconfig-{uuid.uuid4()}.yaml" kubeconf.write_text("apiVersion: v1\nkind: Config\n") # run from within container when talking to k3s directly p = run_kubectl( [ "kubectl", "get", "pods", "--server", server, "--insecure-skip-tls-verify", "--token", user_token, "-n", "default", ] ) assert p.returncode == 0, "mtls upstream token-user combo failed" @pytest.mark.asyncio async def test_kubectl_exec_io(self, processes, shared_wg: WarpgateProcess): """Verify that ``kubectl exec`` through Warpgate proxies stdin/stdout.""" k3s: K3sInstance = processes.start_k3s() k3s_port = k3s.port k3s_token = k3s.token url = f"https://localhost:{shared_wg.http_port}" # --- set up user, role, target --- with admin_client(url) as api: role = api.create_role(sdk.RoleDataRequest(name=f"role-{uuid.uuid4()}")) user = api.create_user( sdk.CreateUserRequest(username=f"user-{uuid.uuid4()}") ) api.create_password_credential( user.id, sdk.NewPasswordCredential(password="123") ) api.add_user_role(user.id, role.id) target_name = f"k8s-exec-{uuid.uuid4()}" with admin_client(url) as api: target = api.create_target( sdk.TargetDataRequest( name=target_name, options=sdk.TargetOptions( sdk.TargetOptionsTargetKubernetesOptions( kind="Kubernetes", cluster_url=f"https://127.0.0.1:{k3s_port}", tls=sdk.Tls(mode=sdk.TlsMode.PREFERRED, verify=False), auth=sdk.KubernetesTargetAuth( sdk.KubernetesTargetAuthKubernetesTargetTokenAuth( kind="Token", token=k3s_token ) ), ) ), ) ) api.add_target_role(target.id, role.id) # login and obtain a user API token async with aiohttp.ClientSession() as session: resp = await session.post( f"{url}/@warpgate/api/auth/login", json={"username": user.username, "password": "123"}, ssl=False, ) resp.raise_for_status() resp = await session.post( f"{url}/@warpgate/api/profile/api-tokens", json={ "label": "exec-token", "expiry": ( datetime.now(timezone.utc) + timedelta(days=1) ).isoformat(), }, ssl=False, ) resp.raise_for_status() user_token = (await resp.json())["secret"] server = f"https://127.0.0.1:{shared_wg.kubernetes_port}/{target_name}" # create a simple pod inside k3s pod_name = f"exec-test-{uuid.uuid4().hex[:8]}" pod_yaml = ( f"apiVersion: v1\n" f"kind: Pod\n" f"metadata:\n" f" name: {pod_name}\n" f" namespace: default\n" f"spec:\n" f" containers:\n" f" - name: alpine\n" f" image: alpine:3\n" f" command: ['sleep', '3600']\n" ) k3s.kubectl(["apply", "-f", "-"], input=pod_yaml.encode()) # wait for the pod to be Running for _ in range(120): r = k3s.kubectl( [ "get", "pod", pod_name, "-n", "default", "-o", "jsonpath={.status.phase}", ], check=False, ) if r.stdout.strip() == b"Running": break time.sleep(1) else: raise AssertionError( f"pod {pod_name} did not reach Running: {r.stdout!r} {r.stderr!r}" ) # --- kubectl exec: send stdin and read stdout --- p = run_kubectl( [ "kubectl", "--server", server, "--insecure-skip-tls-verify", "--token", user_token, "exec", "-i", "-n", "default", pod_name, "--", "cat", ], input=b"hello-from-exec\n", timeout=30, ) assert p.returncode == 0, f"kubectl exec failed: {p.stderr!r}" assert b"hello-from-exec" in p.stdout, ( f"exec stdout did not contain expected text: {p.stdout!r}" ) @pytest.mark.asyncio async def test_kubectl_attach_io(self, processes, shared_wg: WarpgateProcess): """Verify that ``kubectl attach`` through Warpgate proxies stdin/stdout.""" k3s: K3sInstance = processes.start_k3s() k3s_port = k3s.port k3s_token = k3s.token url = f"https://localhost:{shared_wg.http_port}" # --- set up user, role, target --- with admin_client(url) as api: role = api.create_role(sdk.RoleDataRequest(name=f"role-{uuid.uuid4()}")) user = api.create_user( sdk.CreateUserRequest(username=f"user-{uuid.uuid4()}") ) api.create_password_credential( user.id, sdk.NewPasswordCredential(password="123") ) api.add_user_role(user.id, role.id) target_name = f"k8s-attach-{uuid.uuid4()}" with admin_client(url) as api: target = api.create_target( sdk.TargetDataRequest( name=target_name, options=sdk.TargetOptions( sdk.TargetOptionsTargetKubernetesOptions( kind="Kubernetes", cluster_url=f"https://127.0.0.1:{k3s_port}", tls=sdk.Tls(mode=sdk.TlsMode.PREFERRED, verify=False), auth=sdk.KubernetesTargetAuth( sdk.KubernetesTargetAuthKubernetesTargetTokenAuth( kind="Token", token=k3s_token ) ), ) ), ) ) api.add_target_role(target.id, role.id) # login and obtain a user API token async with aiohttp.ClientSession() as session: resp = await session.post( f"{url}/@warpgate/api/auth/login", json={"username": user.username, "password": "123"}, ssl=False, ) resp.raise_for_status() resp = await session.post( f"{url}/@warpgate/api/profile/api-tokens", json={ "label": "attach-token", "expiry": ( datetime.now(timezone.utc) + timedelta(days=1) ).isoformat(), }, ssl=False, ) resp.raise_for_status() user_token = (await resp.json())["secret"] server = f"https://127.0.0.1:{shared_wg.kubernetes_port}/{target_name}" # create a pod whose main process reads stdin and echoes it back pod_name = f"attach-test-{uuid.uuid4().hex[:8]}" pod_yaml = ( f"apiVersion: v1\n" f"kind: Pod\n" f"metadata:\n" f" name: {pod_name}\n" f" namespace: default\n" f"spec:\n" f" containers:\n" f" - name: cat\n" f" image: alpine:3\n" f" command: ['cat']\n" f" stdin: true\n" f" stdinOnce: true\n" ) k3s.kubectl(["apply", "-f", "-"], input=pod_yaml.encode()) # wait for the pod to be Running for _ in range(120): r = k3s.kubectl( [ "get", "pod", pod_name, "-n", "default", "-o", "jsonpath={.status.phase}", ], check=False, ) if r.stdout.strip() == b"Running": break time.sleep(1) else: raise AssertionError( f"pod {pod_name} did not reach Running: {r.stdout!r} {r.stderr!r}" ) # --- kubectl attach: send stdin and read stdout --- p = run_kubectl( [ "kubectl", "-v9", "--server", server, "--insecure-skip-tls-verify", "--token", user_token, "attach", "-i", "-n", "default", pod_name, ], input=b"hello-from-attach\n", timeout=30, ) assert p.returncode == 0, f"kubectl attach failed: {p.stderr!r}" assert b"hello-from-attach" in p.stdout, ( f"attach stdout did not contain expected text: {p.stdout!r}" ) ================================================ FILE: tests/test_mysql_user_auth_password.py ================================================ # import subprocess # import time # from uuid import uuid4 # from .api_client import ( # api_admin_session, # api_create_target, # api_create_user, # api_create_role, # api_add_role_to_user, # api_add_role_to_target, # ) # from .conftest import WarpgateProcess, ProcessManager # from .util import wait_port, wait_mysql_port, mysql_client_ssl_opt, mysql_client_opts # class Test: # def test( # self, # processes: ProcessManager, # timeout, # shared_wg: WarpgateProcess, # ): # db_port = processes.start_mysql_server() # url = f"https://localhost:{shared_wg.http_port}" # with api_admin_session(url) as session: # role = api_create_role(url, session, {"name": f"role-{uuid4()}"}) # user = api_create_user( # url, # session, # { # "username": f"user-{uuid4()}", # "credentials": [ # { # "kind": "Password", # "hash": "123", # }, # ], # }, # ) # api_add_role_to_user(url, session, user["id"], role["id"]) # target = api_create_target( # url, # session, # { # "name": f"mysql-{uuid4()}", # "options": { # "kind": "MySql", # "host": "localhost", # "port": db_port, # "username": "root", # "password": "123", # "tls": { # "mode": "Preferred", # "verify": False, # }, # }, # }, # ) # api_add_role_to_target(url, session, target["id"], role["id"]) # time.sleep(15) # wait_mysql_port(db_port) # wait_port(shared_wg.mysql_port, recv=False) # time.sleep(15) # client = processes.start( # [ # "mysql", # "--user", # f"{user['username']}#{target['name']}", # "-p123", # "--host", # "127.0.0.1", # "--port", # str(shared_wg.mysql_port), # *mysql_client_opts, # mysql_client_ssl_opt, # "db", # ], # stdin=subprocess.PIPE, # stdout=subprocess.PIPE, # ) # assert b"\ndb\n" in client.communicate(b"show schemas;", timeout=timeout)[0] # assert client.returncode == 0 # client = processes.start( # [ # "mysql", # "--user", # f"{user['username']}#{target['name']}", # "-pwrong", # "--host", # "127.0.0.1", # "--port", # str(shared_wg.mysql_port), # *mysql_client_opts, # mysql_client_ssl_opt, # "db", # ], # stdin=subprocess.PIPE, # stdout=subprocess.PIPE, # ) # client.communicate(b"show schemas;", timeout=timeout) # assert client.returncode != 0 ================================================ FILE: tests/test_postgres_user_auth_in_browser.py ================================================ import os import aiohttp import pytest import subprocess from uuid import uuid4 from .api_client import admin_client, sdk from .conftest import WarpgateProcess, ProcessManager from .util import wait_port class Test: @pytest.mark.asyncio async def test( self, processes: ProcessManager, timeout, shared_wg: WarpgateProcess, ): db_port = processes.start_postgres_server() url = f"https://localhost:{shared_wg.http_port}" with admin_client(url) as api: role = api.create_role(sdk.RoleDataRequest(name=f"role-{uuid4()}")) user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) api.create_password_credential( user.id, sdk.NewPasswordCredential(password="123") ) api.add_user_role(user.id, role.id) api.update_user( user.id, sdk.UserDataRequest( username=user.username, credential_policy=sdk.UserRequireCredentialsPolicy( postgres=[ sdk.CredentialKind.PASSWORD, sdk.CredentialKind.WEBUSERAPPROVAL, ], ), ), ) target = api.create_target( sdk.TargetDataRequest( name=f"postgres-{uuid4()}", options=sdk.TargetOptions( sdk.TargetOptionsTargetPostgresOptions( kind="Postgres", host="localhost", port=db_port, username="user", password="123", tls=sdk.Tls( mode=sdk.TlsMode.PREFERRED, verify=False, ), ) ), ) ) api.add_target_role(target.id, role.id) wait_port(db_port, recv=False) wait_port(shared_wg.postgres_port, recv=False) session = aiohttp.ClientSession() headers = {"Host": f"localhost:{shared_wg.http_port}"} await session.post( f"{url}/@warpgate/api/auth/login", json={ "username": user.username, "password": "123", }, headers=headers, ssl=False, ) ws = await session.ws_connect(url.replace('https:', 'wss:') + '/@warpgate/api/auth/web-auth-requests/stream', ssl=False) client = processes.start( [ "psql", "--user", f"{user.username}#{target.name}", "--host", "127.0.0.1", "--port", str(shared_wg.postgres_port), "db", ], env={"PGPASSWORD": "123", **os.environ}, stdin=subprocess.PIPE, stdout=subprocess.PIPE, ) # First message comes as soon as an authstate is created msg = await ws.receive(5) # Second message is once password is accepted msg = await ws.receive(5) auth_id = msg.data auth_state = await (await session.get(f'{url}/@warpgate/api/auth/state/{auth_id}', ssl=False)).json() assert auth_state['protocol'] == 'PostgreSQL' assert auth_state['state'] == 'WebUserApprovalNeeded' r = await session.post(f'{url}/@warpgate/api/auth/state/{auth_id}/approve', ssl=False) assert r.status == 200 client.stdin.write(b"\r\n") assert b"tbl" in client.communicate(b"\\dt\n", timeout=timeout)[0] assert client.returncode == 0 ================================================ FILE: tests/test_postgres_user_auth_password.py ================================================ import os import subprocess from uuid import uuid4 from .api_client import admin_client, sdk from .conftest import WarpgateProcess, ProcessManager from .util import wait_port class Test: def test( self, processes: ProcessManager, timeout, shared_wg: WarpgateProcess, ): db_port = processes.start_postgres_server() url = f"https://localhost:{shared_wg.http_port}" with admin_client(url) as api: role = api.create_role(sdk.RoleDataRequest(name=f"role-{uuid4()}")) user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) api.create_password_credential( user.id, sdk.NewPasswordCredential(password="123") ) api.add_user_role(user.id, role.id) target = api.create_target(sdk.TargetDataRequest( name=f"postgres-{uuid4()}", options=sdk.TargetOptions(sdk.TargetOptionsTargetPostgresOptions( kind="Postgres", host="localhost", port=db_port, username="user", password="123", tls=sdk.Tls( mode=sdk.TlsMode.PREFERRED, verify=False, ), )), )) api.add_target_role(target.id, role.id) wait_port(db_port, recv=False) wait_port(shared_wg.postgres_port, recv=False) client = processes.start( [ "psql", "--user", f"{user.username}#{target.name}", "--host", "127.0.0.1", "--port", str(shared_wg.postgres_port), "db", ], env={"PGPASSWORD": "123", **os.environ}, stdin=subprocess.PIPE, stdout=subprocess.PIPE, ) assert b"tbl" in client.communicate(b"\\dt\n", timeout=timeout)[0] assert client.returncode == 0 client = processes.start( [ "psql", "--user", f"{user.username}#{target.name}", "--host", "127.0.0.1", "--port", str(shared_wg.postgres_port), "db", ], env={"PGPASSWORD": "wrong", **os.environ}, stdin=subprocess.PIPE, stdout=subprocess.PIPE, ) client.communicate(b"\\dt\n", timeout=timeout) assert client.returncode != 0 ================================================ FILE: tests/test_ssh_client_auth_config.py ================================================ """ Tests for SSH client authentication method configuration via Parameters API. These tests verify that SSH auth methods can be configured via the Parameters API and that the configuration actually affects SSH authentication behavior. """ import time from pathlib import Path from uuid import uuid4 from .api_client import admin_client, sdk from .conftest import ProcessManager, WarpgateProcess from .util import wait_port class TestSSHClientAuthConfigAPI: """Test SSH client authentication method configuration via API.""" def test_get_ssh_auth_parameters( self, shared_wg: WarpgateProcess, ): """Test that SSH auth parameters are returned by the API.""" url = f"https://localhost:{shared_wg.http_port}" with admin_client(url) as api: params = api.get_parameters() # Verify the new SSH auth fields exist and default to True assert hasattr(params, 'ssh_client_auth_publickey') assert hasattr(params, 'ssh_client_auth_password') assert hasattr(params, 'ssh_client_auth_keyboard_interactive') # Default values should be True assert params.ssh_client_auth_publickey is True assert params.ssh_client_auth_password is True assert params.ssh_client_auth_keyboard_interactive is True def test_update_ssh_auth_parameters( self, shared_wg: WarpgateProcess, ): """Test that SSH auth parameters can be updated via the API.""" url = f"https://localhost:{shared_wg.http_port}" with admin_client(url) as api: # Get current parameters params = api.get_parameters() # Update to disable password auth api.update_parameters(sdk.ParameterUpdate( allow_own_credential_management=params.allow_own_credential_management, rate_limit_bytes_per_second=params.rate_limit_bytes_per_second, ssh_client_auth_publickey=True, ssh_client_auth_password=False, ssh_client_auth_keyboard_interactive=True, )) # Verify the update updated_params = api.get_parameters() assert updated_params.ssh_client_auth_password is False assert updated_params.ssh_client_auth_publickey is True # Restore original settings api.update_parameters(sdk.ParameterUpdate( allow_own_credential_management=params.allow_own_credential_management, rate_limit_bytes_per_second=params.rate_limit_bytes_per_second, ssh_client_auth_publickey=True, ssh_client_auth_password=True, ssh_client_auth_keyboard_interactive=True, )) class TestSSHClientAuthConfigE2E: """E2E tests verifying SSH auth methods are actually enforced.""" def _start_ssh_server(self, processes, wg_c_ed25519_pubkey): """Start SSH server with delay for Docker port forwarding.""" ssh_port = processes.start_ssh_server( trusted_keys=[wg_c_ed25519_pubkey.read_text()] ) # Give Docker time to set up port forwarding time.sleep(3) wait_port(ssh_port) return ssh_port def _setup_user_and_target(self, api, ssh_port, wg_c_ed25519_pubkey: Path): """Helper to create user, credentials, and target.""" role = api.create_role( sdk.RoleDataRequest(name=f"role-{uuid4()}"), ) user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) # Add password credential api.create_password_credential( user.id, sdk.NewPasswordCredential(password="testpass123") ) # Add pubkey credential api.create_public_key_credential( user.id, sdk.NewPublicKeyCredential( label="Public Key", openssh_public_key=open("ssh-keys/id_ed25519.pub").read().strip() ), ) api.add_user_role(user.id, role.id) ssh_target = api.create_target( sdk.TargetDataRequest( name=f"ssh-{uuid4()}", options=sdk.TargetOptions( sdk.TargetOptionsTargetSSHOptions( kind="Ssh", host="localhost", port=ssh_port, username="root", auth=sdk.SSHTargetAuth( sdk.SSHTargetAuthSshTargetPublicKeyAuth( kind="PublicKey" ) ), ) ), ) ) api.add_target_role(ssh_target.id, role.id) return user, ssh_target def _update_ssh_auth_params(self, api, pubkey=True, password=True, keyboard_interactive=True): """Helper to update SSH auth parameters.""" params = api.get_parameters() api.update_parameters(sdk.ParameterUpdate( allow_own_credential_management=params.allow_own_credential_management, rate_limit_bytes_per_second=params.rate_limit_bytes_per_second, ssh_client_auth_publickey=pubkey, ssh_client_auth_password=password, ssh_client_auth_keyboard_interactive=keyboard_interactive, )) def test_password_auth_disabled( self, processes: ProcessManager, wg_c_ed25519_pubkey: Path, timeout, shared_wg: WarpgateProcess, ): """Test that disabling password auth blocks password-based SSH login.""" ssh_port = self._start_ssh_server(processes, wg_c_ed25519_pubkey) wg = processes.start_wg() wait_port(wg.http_port, for_process=wg.process, recv=False) wait_port(wg.ssh_port, for_process=wg.process) url = f"https://localhost:{wg.http_port}" with admin_client(url) as api: user, ssh_target = self._setup_user_and_target(api, ssh_port, wg_c_ed25519_pubkey) self._update_ssh_auth_params(api, pubkey=True, password=False, keyboard_interactive=False) wg.process.terminate() wg.process.wait() wg2 = processes.start_wg(share_with=wg) wait_port(wg2.http_port, for_process=wg2.process, recv=False) wait_port(wg2.ssh_port, for_process=wg2.process) # Try password auth - should fail ssh_client = processes.start_ssh_client( f"{user.username}:{ssh_target.name}@localhost", "-p", str(wg2.ssh_port), "-i", "/dev/null", "-o", "PreferredAuthentications=password", "-o", "NumberOfPasswordPrompts=1", "ls", "/bin/sh", password="testpass123", ) ssh_client.communicate(timeout=timeout) assert ssh_client.returncode != 0 def test_pubkey_auth_disabled( self, processes: ProcessManager, wg_c_ed25519_pubkey: Path, timeout, shared_wg: WarpgateProcess, ): """Test that disabling pubkey auth blocks pubkey-based SSH login.""" ssh_port = self._start_ssh_server(processes, wg_c_ed25519_pubkey) wg = processes.start_wg() wait_port(wg.http_port, for_process=wg.process, recv=False) wait_port(wg.ssh_port, for_process=wg.process) url = f"https://localhost:{wg.http_port}" with admin_client(url) as api: user, ssh_target = self._setup_user_and_target(api, ssh_port, wg_c_ed25519_pubkey) self._update_ssh_auth_params(api, pubkey=False, password=True, keyboard_interactive=False) wg.process.terminate() wg.process.wait() wg2 = processes.start_wg(share_with=wg) wait_port(wg2.http_port, for_process=wg2.process, recv=False) wait_port(wg2.ssh_port, for_process=wg2.process) # Try pubkey auth - should fail ssh_client = processes.start_ssh_client( f"{user.username}:{ssh_target.name}@localhost", "-p", str(wg2.ssh_port), "-o", "IdentityFile=ssh-keys/id_ed25519", "-o", "PreferredAuthentications=publickey", "ls", "/bin/sh", ) ssh_client.communicate(timeout=timeout) assert ssh_client.returncode != 0 def test_pubkey_auth_enabled_works( self, processes: ProcessManager, wg_c_ed25519_pubkey: Path, timeout, shared_wg: WarpgateProcess, ): """Test that pubkey auth works when enabled.""" ssh_port = self._start_ssh_server(processes, wg_c_ed25519_pubkey) wg = processes.start_wg() wait_port(wg.http_port, for_process=wg.process, recv=False) wait_port(wg.ssh_port, for_process=wg.process) url = f"https://localhost:{wg.http_port}" with admin_client(url) as api: user, ssh_target = self._setup_user_and_target(api, ssh_port, wg_c_ed25519_pubkey) self._update_ssh_auth_params(api, pubkey=True, password=False, keyboard_interactive=False) wg.process.terminate() wg.process.wait() wg2 = processes.start_wg(share_with=wg) wait_port(wg2.http_port, for_process=wg2.process, recv=False) wait_port(wg2.ssh_port, for_process=wg2.process) # Try pubkey auth - should succeed ssh_client = processes.start_ssh_client( f"{user.username}:{ssh_target.name}@localhost", "-p", str(wg2.ssh_port), "-o", "IdentityFile=ssh-keys/id_ed25519", "-o", "PreferredAuthentications=publickey", "ls", "/bin/sh", ) output, _ = ssh_client.communicate(timeout=timeout) assert output == b"/bin/sh\n" assert ssh_client.returncode == 0 def test_password_auth_enabled_works( self, processes: ProcessManager, wg_c_ed25519_pubkey: Path, timeout, shared_wg: WarpgateProcess, ): """Test that password auth works when enabled.""" ssh_port = self._start_ssh_server(processes, wg_c_ed25519_pubkey) wg = processes.start_wg() wait_port(wg.http_port, for_process=wg.process, recv=False) wait_port(wg.ssh_port, for_process=wg.process) url = f"https://localhost:{wg.http_port}" with admin_client(url) as api: user, ssh_target = self._setup_user_and_target(api, ssh_port, wg_c_ed25519_pubkey) self._update_ssh_auth_params(api, pubkey=False, password=True, keyboard_interactive=False) wg.process.terminate() wg.process.wait() wg2 = processes.start_wg(share_with=wg) wait_port(wg2.http_port, for_process=wg2.process, recv=False) wait_port(wg2.ssh_port, for_process=wg2.process) # Try password auth - should succeed ssh_client = processes.start_ssh_client( f"{user.username}:{ssh_target.name}@localhost", "-p", str(wg2.ssh_port), "-i", "/dev/null", "-o", "PreferredAuthentications=password", "ls", "/bin/sh", password="testpass123", ) output, _ = ssh_client.communicate(timeout=timeout) assert output == b"/bin/sh\n" assert ssh_client.returncode == 0 def test_both_auth_methods_work( self, processes: ProcessManager, wg_c_ed25519_pubkey: Path, timeout, shared_wg: WarpgateProcess, ): """Test both pubkey and password work when both enabled.""" ssh_port = self._start_ssh_server(processes, wg_c_ed25519_pubkey) wg = processes.start_wg() wait_port(wg.http_port, for_process=wg.process, recv=False) wait_port(wg.ssh_port, for_process=wg.process) url = f"https://localhost:{wg.http_port}" with admin_client(url) as api: user, ssh_target = self._setup_user_and_target(api, ssh_port, wg_c_ed25519_pubkey) self._update_ssh_auth_params(api, pubkey=True, password=True, keyboard_interactive=False) wg.process.terminate() wg.process.wait() wg2 = processes.start_wg(share_with=wg) wait_port(wg2.http_port, for_process=wg2.process, recv=False) wait_port(wg2.ssh_port, for_process=wg2.process) # Pubkey should work ssh_client = processes.start_ssh_client( f"{user.username}:{ssh_target.name}@localhost", "-p", str(wg2.ssh_port), "-o", "IdentityFile=ssh-keys/id_ed25519", "-o", "PreferredAuthentications=publickey", "ls", "/bin/sh", ) output, _ = ssh_client.communicate(timeout=timeout) assert output == b"/bin/sh\n" assert ssh_client.returncode == 0 # Password should also work ssh_client = processes.start_ssh_client( f"{user.username}:{ssh_target.name}@localhost", "-p", str(wg2.ssh_port), "-i", "/dev/null", "-o", "PreferredAuthentications=password", "ls", "/bin/sh", password="testpass123", ) output, _ = ssh_client.communicate(timeout=timeout) assert output == b"/bin/sh\n" assert ssh_client.returncode == 0 ================================================ FILE: tests/test_ssh_proto.py ================================================ from uuid import uuid4 import requests import subprocess import tempfile import time import pytest from textwrap import dedent from .api_client import admin_client, sdk from .conftest import ProcessManager, WarpgateProcess from .util import wait_port, alloc_port @pytest.fixture(scope="session") def ssh_port(processes, wg_c_ed25519_pubkey): yield processes.start_ssh_server(trusted_keys=[wg_c_ed25519_pubkey.read_text()]) common_args = [ "-i", "/dev/null", "-o", "PreferredAuthentications=password", ] def setup_user_and_target( processes: ProcessManager, wg: WarpgateProcess, warpgate_client_key, extra_config='', ): ssh_port = processes.start_ssh_server( trusted_keys=[warpgate_client_key.read_text()], extra_config=extra_config, ) wait_port(ssh_port) url = f"https://localhost:{wg.http_port}" with admin_client(url) as api: role = api.create_role( sdk.RoleDataRequest(name=f"role-{uuid4()}"), ) user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) api.create_password_credential( user.id, sdk.NewPasswordCredential(password="123") ) api.create_public_key_credential( user.id, sdk.NewPublicKeyCredential( label="Public Key", openssh_public_key=open("ssh-keys/id_ed25519.pub").read().strip(), ), ) api.add_user_role(user.id, role.id) ssh_target = api.create_target( sdk.TargetDataRequest( name=f"ssh-{uuid4()}", options=sdk.TargetOptions( sdk.TargetOptionsTargetSSHOptions( kind="Ssh", host="localhost", port=ssh_port, username="root", auth=sdk.SSHTargetAuth( sdk.SSHTargetAuthSshTargetPublicKeyAuth(kind="PublicKey") ), ) ), ) ) api.add_target_role(ssh_target.id, role.id) return user, ssh_target class Test: def test_stdout_stderr( self, processes: ProcessManager, timeout, wg_c_ed25519_pubkey, shared_wg: WarpgateProcess, ): user, ssh_target = setup_user_and_target( processes, shared_wg, wg_c_ed25519_pubkey ) ssh_client = processes.start_ssh_client( f"{user.username}:{ssh_target.name}@localhost", "-p", str(shared_wg.ssh_port), *common_args, "sh", "-c", '"echo -n stdout; echo -n stderr >&2"', password="123", stderr=subprocess.PIPE, ) stdout, stderr = ssh_client.communicate(timeout=timeout) assert b"stdout" == stdout assert stderr.endswith(b"stderr") def test_pty( self, processes: ProcessManager, timeout, wg_c_ed25519_pubkey, shared_wg: WarpgateProcess, ): user, ssh_target = setup_user_and_target( processes, shared_wg, wg_c_ed25519_pubkey ) ssh_client = processes.start_ssh_client( f"{user.username}:{ssh_target.name}@localhost", "-p", str(shared_wg.ssh_port), "-tt", *common_args, "echo", "hello", password="123", ) output = ssh_client.communicate(timeout=timeout)[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_c_ed25519_pubkey, shared_wg: WarpgateProcess, ): user, ssh_target = setup_user_and_target( processes, shared_wg, wg_c_ed25519_pubkey ) ssh_client = processes.start_ssh_client( f"{user.username}:{ssh_target.name}@localhost", "-p", str(shared_wg.ssh_port), "-v", *common_args, "sh", "-c", '"pkill -9 sh"', password="123", ) assert ssh_client.returncode != 0 def test_direct_tcpip( self, processes: ProcessManager, wg_c_ed25519_pubkey, shared_wg: WarpgateProcess, timeout, ): user, ssh_target = setup_user_and_target( processes, shared_wg, wg_c_ed25519_pubkey ) local_port = alloc_port() ssh_client = processes.start_ssh_client( f"{user.username}:{ssh_target.name}@localhost", "-p", str(shared_wg.ssh_port), "-v", *common_args, "-L", f"{local_port}:github.com:443", "-N", password="123", ) time.sleep(10) wait_port(local_port, recv=False) s = requests.Session() retries = requests.adapters.Retry(total=5, backoff_factor=1) s.mount("https://", requests.adapters.HTTPAdapter(max_retries=retries)) response = s.get(f"https://localhost:{local_port}", timeout=timeout, verify=False) assert response.status_code == 200 ssh_client.kill() def test_tcpip_forward( self, processes: ProcessManager, wg_c_ed25519_pubkey, shared_wg: WarpgateProcess, timeout, ): user, ssh_target = setup_user_and_target( processes, shared_wg, wg_c_ed25519_pubkey ) fw_port = alloc_port() pf_client = processes.start_ssh_client( f"{user.username}:{ssh_target.name}@localhost", "-p", str(shared_wg.ssh_port), "-v", *common_args, "-R", f"{fw_port}:www.google.com:443", "-N", password="123", ) # time.sleep(5) ssh_client = processes.start_ssh_client( f"{user.username}:{ssh_target.name}@localhost", "-p", str(shared_wg.ssh_port), "-v", *common_args, "curl", "-vk", "--http1.1", "-H", "Host: www.google.com", f"https://localhost:{fw_port}", password="123", ) output = ssh_client.communicate(timeout=timeout)[0] assert ssh_client.returncode == 0 assert b"" in output pf_client.kill() def test_shell( self, processes: ProcessManager, wg_c_ed25519_pubkey, shared_wg: WarpgateProcess, timeout, ): user, ssh_target = setup_user_and_target( processes, shared_wg, wg_c_ed25519_pubkey ) script = dedent( f""" set timeout {timeout - 5} spawn ssh -tt {user.username}:{ssh_target.name}@localhost -p {shared_wg.ssh_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(), timeout=timeout)[0] assert ssh_client.returncode == 0, output def test_connection_error( self, processes: ProcessManager, wg_c_ed25519_pubkey, shared_wg: WarpgateProcess, ): user, ssh_target = setup_user_and_target( processes, shared_wg, wg_c_ed25519_pubkey ) ssh_client = processes.start_ssh_client( f"{user.username}:{ssh_target.name}@localhost", "-p", str(shared_wg.ssh_port), "-tt", "user:ssh-bad-domain@localhost", "-i", "/dev/null", "-o", "PreferredAuthentications=password", password="123", ) assert ssh_client.returncode != 0 def test_sftp( self, processes: ProcessManager, wg_c_ed25519_pubkey, shared_wg: WarpgateProcess, ): user, ssh_target = setup_user_and_target( processes, shared_wg, wg_c_ed25519_pubkey ) with tempfile.TemporaryDirectory() as f: subprocess.check_call( [ "sftp", "-P", str(shared_wg.ssh_port), "-o", f"User={user.username}:{ssh_target.name}", "-o", "IdentitiesOnly=yes", "-o", "IdentityFile=ssh-keys/id_ed25519", "-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() def test_insecure_protos( self, processes: ProcessManager, timeout, wg_c_rsa_pubkey, shared_wg: WarpgateProcess, ): user, ssh_target = setup_user_and_target( processes, shared_wg, wg_c_rsa_pubkey, extra_config=''' PubkeyAcceptedKeyTypes=ssh-rsa ''', ) ssh_client = processes.start_ssh_client( f"{user.username}:{ssh_target.name}@localhost", "-p", str(shared_wg.ssh_port), *common_args, "echo", "123", password="123", stderr=subprocess.PIPE, ) ssh_client.wait(timeout=timeout) assert ssh_client.returncode != 0 ssh_target.options.actual_instance.allow_insecure_algos = True url = f"https://localhost:{shared_wg.http_port}" with admin_client(url) as api: api.update_target(ssh_target.id, sdk.TargetDataRequest( name=ssh_target.name, options=ssh_target.options, )) ssh_client = processes.start_ssh_client( f"{user.username}:{ssh_target.name}@localhost", "-p", str(shared_wg.ssh_port), *common_args, "echo", "123", password="123", ) stdout, _ = ssh_client.communicate(timeout=timeout) assert b"123\n" == stdout ================================================ FILE: tests/test_ssh_target_selection.py ================================================ from pathlib import Path import subprocess from uuid import uuid4 from .api_client import admin_client, sdk from .conftest import ProcessManager, WarpgateProcess from .util import wait_port class Test: def test_bad_target( self, processes: ProcessManager, wg_c_ed25519_pubkey: Path, shared_wg: WarpgateProcess, ): ssh_port = processes.start_ssh_server( trusted_keys=[wg_c_ed25519_pubkey.read_text()] ) wait_port(ssh_port) url = f"https://localhost:{shared_wg.http_port}" with admin_client(url) as api: role = api.create_role( sdk.RoleDataRequest(name=f"role-{uuid4()}"), ) user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) api.create_password_credential(user.id, sdk.NewPasswordCredential(password="123")) api.add_user_role(user.id, role.id) ssh_target = api.create_target(sdk.TargetDataRequest( name=f"ssh-{uuid4()}", options=sdk.TargetOptions( sdk.TargetOptionsTargetSSHOptions( kind="Ssh", host="localhost", port=ssh_port, username="root", auth=sdk.SSHTargetAuth( sdk.SSHTargetAuthSshTargetPublicKeyAuth(kind="PublicKey") ), ) ), )) api.add_target_role(ssh_target.id, role.id) ssh_client = processes.start_ssh_client( "-t", f"{user.username}:badtarget@localhost", "-p", str(shared_wg.ssh_port), "-i", "/dev/null", "-o", "PreferredAuthentications=password", "echo", "hello", stderr=subprocess.PIPE, password="123", ) assert ssh_client.returncode != 0 assert b"Permission denied" in ssh_client.stderr.read() ================================================ FILE: tests/test_ssh_user_auth_in_browser.py ================================================ import aiohttp import pytest from pathlib import Path from uuid import uuid4 from .api_client import admin_client, sdk from .conftest import ProcessManager, WarpgateProcess from .util import wait_port class Test: # When include_pk is False, we're testing for # https://github.com/warp-tech/warpgate/issues/972 # where the SSH server fails to offer keyboard-interactive authentication # when no OTP credential is present. @pytest.mark.parametrize("include_pk", [True, False]) @pytest.mark.asyncio async def test( self, processes: ProcessManager, wg_c_ed25519_pubkey: Path, timeout, shared_wg: WarpgateProcess, include_pk: bool, ): ssh_port = processes.start_ssh_server( trusted_keys=[wg_c_ed25519_pubkey.read_text()] ) wait_port(ssh_port) url = f"https://localhost:{shared_wg.http_port}" with admin_client(url) as api: role = api.create_role( sdk.RoleDataRequest(name=f"role-{uuid4()}"), ) user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) api.create_password_credential( user.id, sdk.NewPasswordCredential(password="123") ) if include_pk: api.create_public_key_credential( user.id, sdk.NewPublicKeyCredential( label="Public Key", openssh_public_key=open("ssh-keys/id_ed25519.pub").read().strip() ), ) api.add_user_role(user.id, role.id) api.update_user( user.id, sdk.UserDataRequest( username=user.username, credential_policy=sdk.UserRequireCredentialsPolicy( ssh=[sdk.CredentialKind.WEBUSERAPPROVAL] if not include_pk else [ sdk.CredentialKind.PUBLICKEY, sdk.CredentialKind.WEBUSERAPPROVAL, ], ), ), ) ssh_target = api.create_target( sdk.TargetDataRequest( name=f"ssh-{uuid4()}", options=sdk.TargetOptions( sdk.TargetOptionsTargetSSHOptions( kind="Ssh", host="localhost", port=ssh_port, username="root", auth=sdk.SSHTargetAuth( sdk.SSHTargetAuthSshTargetPublicKeyAuth( kind="PublicKey" ) ), ) ), ) ) api.add_target_role(ssh_target.id, role.id) session = aiohttp.ClientSession() headers = {"Host": f"localhost:{shared_wg.http_port}"} await session.post( f"{url}/@warpgate/api/auth/login", json={ "username": user.username, "password": "123", }, headers=headers, ssl=False, ) ws = await session.ws_connect(url.replace('https:', 'wss:') + '/@warpgate/api/auth/web-auth-requests/stream', ssl=False) ssh_client = processes.start_ssh_client( f"{user.username}:{ssh_target.name}@localhost", "-p", str(shared_wg.ssh_port), "-o", "IdentityFile=ssh-keys/id_ed25519", "ls", "/bin/sh", ) msg = await ws.receive(5) auth_id = msg.data auth_state = await (await session.get(f'{url}/@warpgate/api/auth/state/{auth_id}', ssl=False)).json() assert auth_state['protocol'] == 'SSH' assert auth_state['state'] == 'WebUserApprovalNeeded' r = await session.post(f'{url}/@warpgate/api/auth/state/{auth_id}/approve', ssl=False) assert r.status == 200 ssh_client.stdin.write(b"\r\n") assert ssh_client.communicate(timeout=timeout)[0] == b"/bin/sh\n" assert ssh_client.returncode == 0 ================================================ FILE: tests/test_ssh_user_auth_otp.py ================================================ from asyncio import subprocess from base64 import b64decode from uuid import uuid4 import pyotp import pytest from pathlib import Path from textwrap import dedent from .api_client import admin_client, sdk from .conftest import ProcessManager, WarpgateProcess 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, timeout, shared_wg: WarpgateProcess, ): ssh_port = processes.start_ssh_server( trusted_keys=[wg_c_ed25519_pubkey.read_text()] ) wait_port(ssh_port) url = f"https://localhost:{shared_wg.http_port}" with admin_client(url) as api: role = api.create_role( sdk.RoleDataRequest(name=f"role-{uuid4()}"), ) user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) api.create_public_key_credential( user.id, sdk.NewPublicKeyCredential( label="Public Key", openssh_public_key=open("ssh-keys/id_ed25519.pub").read().strip(), ), ) api.create_otp_credential( user.id, sdk.NewOtpCredential( secret_key=list(b64decode(otp_key_base64)), ), ) api.update_user( user.id, sdk.UserDataRequest( username=user.username, credential_policy=sdk.UserRequireCredentialsPolicy( ssh=["PublicKey", "Totp"], ), ), ) api.add_user_role(user.id, role.id) ssh_target = api.create_target( sdk.TargetDataRequest( name=f"ssh-{uuid4()}", options=sdk.TargetOptions( sdk.TargetOptionsTargetSSHOptions( kind="Ssh", host="localhost", port=ssh_port, username="root", auth=sdk.SSHTargetAuth( sdk.SSHTargetAuthSshTargetPublicKeyAuth( kind="PublicKey" ) ), ) ), ) ) api.add_target_role(ssh_target.id, role.id) totp = pyotp.TOTP(otp_key_base32) script = dedent( f""" set timeout {timeout - 5} spawn ssh {user.username}:{ssh_target.name}@localhost -p {shared_wg.ssh_port} -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(), timeout=timeout) assert ssh_client.returncode == 0, output + stderr script = dedent( f""" set timeout {timeout - 5} spawn ssh {user.username}:{ssh_target.name}@localhost -p {[shared_wg.ssh_port]} -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(), timeout=timeout)[0] assert ssh_client.returncode != 0, output ================================================ FILE: tests/test_ssh_user_auth_password.py ================================================ from pathlib import Path from uuid import uuid4 from .api_client import admin_client, sdk from .conftest import ProcessManager, WarpgateProcess from .util import wait_port class Test: def test( self, processes: ProcessManager, wg_c_ed25519_pubkey: Path, timeout, shared_wg: WarpgateProcess, ): ssh_port = processes.start_ssh_server( trusted_keys=[wg_c_ed25519_pubkey.read_text()] ) wait_port(ssh_port) url = f"https://localhost:{shared_wg.http_port}" with admin_client(url) as api: role = api.create_role( sdk.RoleDataRequest(name=f"role-{uuid4()}"), ) user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) api.create_password_credential( user.id, sdk.NewPasswordCredential(password="123") ) api.add_user_role(user.id, role.id) ssh_target = api.create_target( sdk.TargetDataRequest( name=f"ssh-{uuid4()}", options=sdk.TargetOptions( sdk.TargetOptionsTargetSSHOptions( kind="Ssh", host="localhost", port=ssh_port, username="root", auth=sdk.SSHTargetAuth( sdk.SSHTargetAuthSshTargetPublicKeyAuth( kind="PublicKey" ) ), ) ), ) ) api.add_target_role(ssh_target.id, role.id) ssh_client = processes.start_ssh_client( f"{user.username}:{ssh_target.name}@localhost", "-v", "-p", str(shared_wg.ssh_port), "-i", "/dev/null", "-o", "PreferredAuthentications=password", "ls", "/bin/sh", password="123", ) assert ssh_client.communicate(timeout=timeout)[0] == b"/bin/sh\n" assert ssh_client.returncode == 0 ssh_client = processes.start_ssh_client( f"{user.username}:{ssh_target.name}@localhost", "-p", str(shared_wg.ssh_port), "-i", "/dev/null", "-o", "PreferredAuthentications=password", "ls", "/bin/sh", password="321", ) ssh_client.communicate(timeout=timeout) assert ssh_client.returncode != 0 ================================================ FILE: tests/test_ssh_user_auth_pubkey.py ================================================ from pathlib import Path from uuid import uuid4 from .api_client import admin_client, sdk from .conftest import ProcessManager, WarpgateProcess from .util import wait_port class Test: def test_ed25519( self, processes: ProcessManager, wg_c_ed25519_pubkey: Path, timeout, shared_wg: WarpgateProcess, ): ssh_port = processes.start_ssh_server( trusted_keys=[wg_c_ed25519_pubkey.read_text()] ) wait_port(ssh_port) url = f"https://localhost:{shared_wg.http_port}" with admin_client(url) as api: role = api.create_role( sdk.RoleDataRequest(name=f"role-{uuid4()}"), ) user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) api.create_public_key_credential( user.id, sdk.NewPublicKeyCredential( label="Public Key", openssh_public_key=open("ssh-keys/id_ed25519.pub").read().strip() ), ) api.add_user_role(user.id, role.id) ssh_target = api.create_target( sdk.TargetDataRequest( name=f"ssh-{uuid4()}", options=sdk.TargetOptions( sdk.TargetOptionsTargetSSHOptions( kind="Ssh", host="localhost", port=ssh_port, username="root", auth=sdk.SSHTargetAuth( sdk.SSHTargetAuthSshTargetPublicKeyAuth( kind="PublicKey" ) ), ) ), ) ) api.add_target_role(ssh_target.id, role.id) ssh_client = processes.start_ssh_client( f"{user.username}:{ssh_target.name}@localhost", "-p", str(shared_wg.ssh_port), "-o", "IdentityFile=ssh-keys/id_ed25519", "-o", "PreferredAuthentications=publickey", # 'sh', '-c', '"ls /bin/sh;sleep 1"', "ls", "/bin/sh", ) assert ssh_client.communicate(timeout=timeout)[0] == b"/bin/sh\n" assert ssh_client.returncode == 0 ssh_client = processes.start_ssh_client( f"{user.username}:{ssh_target.name}@localhost", "-p", str(shared_wg.ssh_port), "-o", "IdentityFile=ssh-keys/id_rsa", "-o", "PreferredAuthentications=publickey", "ls", "/bin/sh", ) assert ssh_client.communicate(timeout=timeout)[0] == b"" assert ssh_client.returncode != 0 def test_rsa( self, processes: ProcessManager, wg_c_ed25519_pubkey: Path, timeout, shared_wg: WarpgateProcess, ): ssh_port = processes.start_ssh_server( trusted_keys=[wg_c_ed25519_pubkey.read_text()] ) wait_port(ssh_port) url = f"https://localhost:{shared_wg.http_port}" with admin_client(url) as api: role = api.create_role( sdk.RoleDataRequest(name=f"role-{uuid4()}"), ) user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) api.create_public_key_credential( user.id, sdk.NewPublicKeyCredential( label="Public Key", openssh_public_key=open("ssh-keys/id_rsa.pub").read().strip() ), ) api.add_user_role(user.id, role.id) ssh_target = api.create_target(sdk.TargetDataRequest( name=f"ssh-{uuid4()}", options=sdk.TargetOptions( sdk.TargetOptionsTargetSSHOptions( kind="Ssh", host="localhost", port=ssh_port, username="root", auth=sdk.SSHTargetAuth( sdk.SSHTargetAuthSshTargetPublicKeyAuth(kind="PublicKey") ), ) ), )) api.add_target_role(ssh_target.id, role.id) ssh_client = processes.start_ssh_client( f"{user.username}:{ssh_target.name}@localhost", "-v", "-p", str(shared_wg.ssh_port), "-o", "IdentityFile=ssh-keys/id_rsa", "-o", "PreferredAuthentications=publickey", "-o", "PubkeyAcceptedKeyTypes=+ssh-rsa", "ls", "/bin/sh", ) assert ssh_client.communicate(timeout=timeout)[0] == b"/bin/sh\n" assert ssh_client.returncode == 0 ssh_client = processes.start_ssh_client( f"{user.username}:{ssh_target.name}@localhost", "-p", str(shared_wg.ssh_port), "-o", "IdentityFile=ssh-keys/id_ed25519", "-o", "PreferredAuthentications=publickey", "-o", "PubkeyAcceptedKeyTypes=+ssh-rsa", "ls", "/bin/sh", ) assert ssh_client.communicate(timeout=timeout)[0] == b"" assert ssh_client.returncode != 0 ================================================ FILE: tests/test_ssh_user_auth_ticket.py ================================================ from pathlib import Path from uuid import uuid4 from .api_client import admin_client, sdk from .conftest import ProcessManager, WarpgateProcess from .util import wait_port class Test: def test( self, processes: ProcessManager, wg_c_ed25519_pubkey: Path, timeout, shared_wg: WarpgateProcess, ): ssh_port = processes.start_ssh_server( trusted_keys=[wg_c_ed25519_pubkey.read_text()] ) wait_port(ssh_port) url = f"https://localhost:{shared_wg.http_port}" with admin_client(url) as api: role = api.create_role( sdk.RoleDataRequest(name=f"role-{uuid4()}"), ) user = api.create_user(sdk.CreateUserRequest(username=f"user-{uuid4()}")) api.create_password_credential( user.id, sdk.NewPasswordCredential(password="123") ) api.add_user_role(user.id, role.id) ssh_target = api.create_target( sdk.TargetDataRequest( name=f"ssh-{uuid4()}", options=sdk.TargetOptions( sdk.TargetOptionsTargetSSHOptions( kind="Ssh", host="localhost", port=ssh_port, username="root", auth=sdk.SSHTargetAuth( sdk.SSHTargetAuthSshTargetPublicKeyAuth( kind="PublicKey" ) ), ) ), ) ) api.add_target_role(ssh_target.id, role.id) secret = api.create_ticket( sdk.CreateTicketRequest( target_name=ssh_target.name, username=user.username, ) ).secret ssh_client = processes.start_ssh_client( f"ticket-{secret}@localhost", "-p", str(shared_wg.ssh_port), "-i", "/dev/null", "-o", "PreferredAuthentications=password", "ls", "/bin/sh", password="123", ) assert ssh_client.communicate(timeout=timeout)[0] == b"/bin/sh\n" assert ssh_client.returncode == 0 ================================================ FILE: tests/util.py ================================================ import logging import os import requests import socket import subprocess import threading import time last_port = 1234 mysql_client_ssl_opt = "--ssl" mysql_client_opts = [] if "GITHUB_ACTION" in os.environ: # Github uses MySQL instead of MariaDB mysql_client_ssl_opt = "--ssl-mode=REQUIRED" mysql_client_opts = ["--enable-cleartext-plugin"] def alloc_port(): global last_port last_port += 1 return last_port def _wait_timeout(fn, msg, timeout=60): t = threading.Thread(target=fn, daemon=True) t.start() t.join(timeout=timeout) if t.is_alive(): raise Exception(msg) def wait_port(port, recv=True, timeout=60, for_process: subprocess.Popen = None, connect_timeout=5, read_timeout=5): logging.debug(f"Waiting for port {port}") def wait(): while True: try: s = socket.create_connection(("localhost", port), timeout=connect_timeout) if recv: s.settimeout(read_timeout) if not s.recv(100): raise Exception("Port is open but not responding") s.close() logging.debug(f"Port {port} is up") return except socket.error: if for_process: try: for_process.wait(timeout=0.1) raise Exception("Process exited while waiting for port") except subprocess.TimeoutExpired: continue else: time.sleep(0.1) _wait_timeout(wait, f"Port {port} is not up", timeout=timeout) 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"] ================================================ FILE: warpgate/.gitignore ================================================ !Cargo.lock ================================================ FILE: warpgate/Cargo.toml ================================================ [package] edition = "2021" license = "Apache-2.0" name = "warpgate" version = "0.22.0" [dependencies] ansi_term = { version = "0.12", default-features = false } anyhow.workspace = true async-trait = { version = "0.1", default-features = false } bytes.workspace = true clap = { version = "4.0", features = ["derive", "env"], default-features = false } config = { version = "0.15", features = ["yaml"], default-features = false } console = { version = "0.15", default-features = false } console-subscriber = { version = "0.4", optional = true, default-features = false } data-encoding.workspace = true dialoguer.workspace = true enum_dispatch.workspace = true futures.workspace = true notify = { version = "8.0", default-features = false, features = ["fsevent-sys"] } rcgen.workspace = true reqwest.workspace = true rustls.workspace = true serde_json.workspace = true serde_yaml = { version = "0.9", default-features = false } sea-orm.workspace = true time = { version = "0.3", default-features = false } tokio.workspace = true tracing.workspace = true tracing-log = { version = "0.2" } tracing-subscriber = { version = "0.3", features = [ "ansi", "env-filter", "local-time", ], default-features = false } uuid = { version = "1.3", default-features = false } warpgate-admin = { version = "*", path = "../warpgate-admin" } warpgate-common = { version = "*", path = "../warpgate-common" } warpgate-ca = { version = "*", path = "../warpgate-ca" } warpgate-core = { version = "*", path = "../warpgate-core" } warpgate-db-entities = { version = "*", path = "../warpgate-db-entities" } warpgate-protocol-http = { version = "*", path = "../warpgate-protocol-http" } warpgate-protocol-mysql = { version = "*", path = "../warpgate-protocol-mysql" } warpgate-protocol-postgres = { version = "*", path = "../warpgate-protocol-postgres" } warpgate-protocol-ssh = { version = "*", path = "../warpgate-protocol-ssh" } warpgate-protocol-kubernetes = { version = "*", path = "../warpgate-protocol-kubernetes" } warpgate-tls = { version = "*", path = "../warpgate-tls" } schemars.workspace = true [target.'cfg(target_os = "linux")'.dependencies] sd-notify = { version = "0.4", default-features = false } [features] default = ["sqlite"] tokio-console = ["dep:console-subscriber", "tokio/tracing"] postgres = ["warpgate-core/postgres"] mysql = ["warpgate-core/mysql"] sqlite = ["warpgate-core/sqlite"] ================================================ FILE: warpgate/src/commands/check.rs ================================================ use anyhow::{Context, Result}; use tracing::*; use warpgate_common::GlobalParams; use warpgate_tls::{TlsCertificateBundle, TlsPrivateKey}; use crate::config::load_config; pub(crate) async fn command(params: &GlobalParams) -> Result<()> { let config = load_config(params, true)?; TlsCertificateBundle::from_file( params .paths_relative_to() .join(&config.store.http.certificate), ) .await .with_context(|| "Checking HTTPS certificate".to_string())?; TlsPrivateKey::from_file(params.paths_relative_to().join(&config.store.http.key)) .await .with_context(|| "Checking HTTPS key".to_string())?; if config.store.mysql.enable { TlsCertificateBundle::from_file( params .paths_relative_to() .join(&config.store.mysql.certificate), ) .await .with_context(|| "Checking MySQL certificate".to_string())?; TlsPrivateKey::from_file(params.paths_relative_to().join(&config.store.mysql.key)) .await .with_context(|| "Checking MySQL key".to_string())?; } if config.store.postgres.enable { TlsCertificateBundle::from_file( params .paths_relative_to() .join(&config.store.postgres.certificate), ) .await .with_context(|| "Checking PostgreSQL certificate".to_string())?; TlsPrivateKey::from_file(params.paths_relative_to().join(&config.store.postgres.key)) .await .with_context(|| "Checking PostgreSQL key".to_string())?; } info!("No problems found"); Ok(()) } ================================================ FILE: warpgate/src/commands/client_keys.rs ================================================ use anyhow::Result; use warpgate_common::GlobalParams; use crate::config::load_config; pub(crate) async fn command(params: &GlobalParams) -> Result<()> { let config = load_config(params, true)?; let keys = warpgate_protocol_ssh::load_keys(&config, params, "client")?; println!("Warpgate SSH client keys:"); println!("(add these to your target's authorized_keys file)"); println!(); for key in keys { println!("{}", key.public_key().to_openssh()?); } Ok(()) } ================================================ FILE: warpgate/src/commands/common.rs ================================================ use std::io::IsTerminal; use tracing::*; pub(crate) fn assert_interactive_terminal() { if !std::io::stdin().is_terminal() { error!("Please run this command from an interactive terminal."); if is_docker() { info!("(have you forgotten `-it`?)"); } std::process::exit(1); } } pub(crate) fn is_docker() -> bool { std::env::var("DOCKER").is_ok() } ================================================ FILE: warpgate/src/commands/create_user.rs ================================================ use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; use uuid::Uuid; use warpgate_common::{ GlobalParams, Secret, UserPasswordCredential, UserRequireCredentialsPolicy, WarpgateError, }; use warpgate_core::Services; use warpgate_db_entities::{ AdminRole, PasswordCredential, Role, User, UserAdminRoleAssignment, UserRoleAssignment, }; use crate::config::load_config; pub(crate) async fn command( params: &GlobalParams, username: &str, password: &Secret, role: &Option, ) -> anyhow::Result<()> { let config = load_config(params, true)?; let services = Services::new(config.clone(), None, params.clone()).await?; let db = services.db.lock().await; let db_user = match User::Entity::find() .filter(User::Column::Username.eq(username)) .all(&*db) .await? .first() { Some(x) => x.to_owned(), None => { let values = User::ActiveModel { id: Set(Uuid::new_v4()), username: Set(username.to_owned()), description: Set("".into()), credential_policy: Set(serde_json::to_value(None::)?), rate_limit_bytes_per_second: Set(None), ldap_server_id: Set(None), ldap_object_uuid: Set(None), }; values.insert(&*db).await.map_err(WarpgateError::from)? } }; PasswordCredential::ActiveModel { user_id: Set(db_user.id), id: Set(Uuid::new_v4()), ..UserPasswordCredential::from_password(password).into() } .insert(&*db) .await?; if let Some(role_name) = role { // try regular role first if let Some(db_role) = Role::Entity::find() .filter(Role::Column::Name.eq(role_name.clone())) .one(&*db) .await? { if UserRoleAssignment::Entity::find() .filter(UserRoleAssignment::Column::UserId.eq(db_user.id)) .filter(UserRoleAssignment::Column::RoleId.eq(db_role.id)) .all(&*db) .await? .is_empty() { let values = UserRoleAssignment::ActiveModel { user_id: Set(db_user.id), role_id: Set(db_role.id), ..Default::default() }; values.insert(&*db).await.map_err(WarpgateError::from)?; } } // admin role if let Some(db_admin) = AdminRole::Entity::find() .filter(AdminRole::Column::Name.eq(role_name.clone())) .one(&*db) .await? { if UserAdminRoleAssignment::Entity::find() .filter(UserAdminRoleAssignment::Column::UserId.eq(db_user.id)) .filter(UserAdminRoleAssignment::Column::AdminRoleId.eq(db_admin.id)) .all(&*db) .await? .is_empty() { let values = UserAdminRoleAssignment::ActiveModel { user_id: Set(db_user.id), admin_role_id: Set(db_admin.id), ..Default::default() }; values.insert(&*db).await.map_err(WarpgateError::from)?; } } } Ok(()) } ================================================ FILE: warpgate/src/commands/healthcheck.rs ================================================ use anyhow::{Context, Result}; use tokio::time::timeout; use warpgate_common::GlobalParams; use crate::config::load_config; pub(crate) async fn command(params: &GlobalParams) -> Result<()> { let config = load_config(params, true)?; let url = format!( "https://{}/@warpgate/api/info", config.store.http.listen.address() ); let client = reqwest::Client::builder() .danger_accept_invalid_certs(true) .use_rustls_tls() .build()?; let response = timeout(std::time::Duration::from_secs(5), client.get(&url).send()) .await .context("Timeout")? .context("Failed to send request")?; response.error_for_status()?; Ok(()) } ================================================ FILE: warpgate/src/commands/mod.rs ================================================ pub mod check; pub mod client_keys; mod common; pub mod create_user; pub mod healthcheck; pub mod recover_access; pub mod run; pub mod setup; ================================================ FILE: warpgate/src/commands/recover_access.rs ================================================ use anyhow::Result; use dialoguer::theme::ColorfulTheme; use sea_orm::{ActiveModelTrait, EntityTrait, QueryOrder, Set}; use tracing::*; use uuid::Uuid; use warpgate_common::auth::CredentialKind; use warpgate_common::{GlobalParams, Secret, User as UserConfig, UserPasswordCredential}; use warpgate_core::Services; use warpgate_db_entities::{PasswordCredential, User}; use crate::commands::common::assert_interactive_terminal; use crate::config::load_config; pub(crate) async fn command(params: &GlobalParams, username: &Option) -> Result<()> { assert_interactive_terminal(); let config = load_config(params, true)?; let services = Services::new(config.clone(), None, params.clone()).await?; warpgate_protocol_ssh::generate_keys(&config, params, "host")?; warpgate_protocol_ssh::generate_keys(&config, params, "client")?; let theme = ColorfulTheme::default(); let db = services.db.lock().await; let users = User::Entity::find() .order_by_asc(User::Column::Username) .all(&*db) .await?; let users: Result, _> = users.into_iter().map(|t| t.try_into()).collect(); let mut users = users?; let usernames = users.iter().map(|x| x.username.clone()).collect::>(); let user = match username { Some(username) => users .iter_mut() .find(|x| &x.username == username) .ok_or_else(|| anyhow::anyhow!("User not found"))?, None => { #[allow(clippy::indexing_slicing)] &mut users[dialoguer::Select::with_theme(&theme) .with_prompt("Select a user to recover access for") .items(&usernames) .default(0) .interact()?] } }; let password = Secret::new( dialoguer::Password::with_theme(&theme) .with_prompt(format!("New password for {}", user.username)) .interact()?, ); if !dialoguer::Confirm::with_theme(&theme) .default(true) .with_prompt("This tool will add a new password for the user and set their HTTP auth policy to only require a password. Continue?") .interact()? { std::process::exit(0); } PasswordCredential::ActiveModel { user_id: Set(user.id), id: Set(Uuid::new_v4()), ..UserPasswordCredential::from_password(&password).into() } .insert(&*db) .await?; user.credential_policy .get_or_insert_with(Default::default) .http = Some(vec![CredentialKind::Password]); User::ActiveModel { id: Set(user.id), credential_policy: Set(serde_json::to_value(Some(&user.credential_policy))?), ..Default::default() } .update(&*db) .await?; info!("All done. You can now log in"); Ok(()) } ================================================ FILE: warpgate/src/commands/run.rs ================================================ use anyhow::{Context, Result}; use futures::{FutureExt, StreamExt}; #[cfg(target_os = "linux")] use sd_notify::NotifyState; use tokio::signal::unix::SignalKind; use tracing::*; use warpgate_common::version::warpgate_version; use warpgate_common::{GlobalParams, ListenEndpoint}; use warpgate_core::db::cleanup_db; use warpgate_core::logging::install_database_logger; use warpgate_core::{ConfigProvider, ProtocolServer, Services}; use warpgate_protocol_http::HTTPProtocolServer; use warpgate_protocol_kubernetes::KubernetesProtocolServer; use warpgate_protocol_mysql::MySQLProtocolServer; use warpgate_protocol_postgres::PostgresProtocolServer; use warpgate_protocol_ssh::SSHProtocolServer; use crate::config::{load_config, watch_config}; async fn run_protocol_server( server: T, address: ListenEndpoint, ) -> Result<()> { let name = server.name(); info!("Accepting {name} connections on {address:?}"); server .run(address) .await .with_context(|| format!("protocol server: {name}")) } pub(crate) async fn command(params: &GlobalParams, enable_admin_token: bool) -> Result<()> { let version = warpgate_version(); info!(%version, "Warpgate"); let admin_token = enable_admin_token.then(|| { std::env::var("WARPGATE_ADMIN_TOKEN").unwrap_or_else(|_| { error!("`WARPGATE_ADMIN_TOKEN` env variable must set when using --enable-admin-token"); std::process::exit(1); }) }); let config = match load_config(params, true) { Ok(config) => config, Err(error) => { error!(?error, "Failed to load config file"); std::process::exit(1); } }; let services = Services::new(config.clone(), admin_token, params.clone()).await?; install_database_logger(services.db.clone()); if console::user_attended() { info!("--------------------------------------------"); info!("Warpgate is now running."); } let mut protocol_futures = futures::stream::FuturesUnordered::new(); protocol_futures.push( run_protocol_server( HTTPProtocolServer::new(&services).await?, config.store.http.listen.clone(), ) .boxed(), ); if config.store.ssh.enable { protocol_futures.push( run_protocol_server( SSHProtocolServer::new(&services).await?, config.store.ssh.listen.clone(), ) .boxed(), ); } if config.store.mysql.enable { protocol_futures.push( run_protocol_server( MySQLProtocolServer::new(&services).await?, config.store.mysql.listen.clone(), ) .boxed(), ); } if config.store.postgres.enable { protocol_futures.push( run_protocol_server( PostgresProtocolServer::new(&services).await?, config.store.postgres.listen.clone(), ) .boxed(), ); } if config.store.kubernetes.enable { protocol_futures.push( KubernetesProtocolServer::new(&services) .await? .run(config.store.kubernetes.listen.clone()) .boxed(), ); } tokio::spawn({ let services = services.clone(); async move { loop { let retention = { services.config.lock().await.store.log.retention }; let interval = retention / 10; #[allow(clippy::explicit_auto_deref)] match cleanup_db( &mut *services.db.lock().await, &mut *services.recordings.lock().await, &retention, ) .await { Err(error) => error!(?error, "Failed to cleanup the database"), Ok(_) => debug!("Database cleaned up, next in {:?}", interval), } tokio::time::sleep(interval).await; } } }); #[cfg(target_os = "linux")] if let Ok(true) = sd_notify::booted() { use std::time::Duration; tokio::spawn(async { if let Err(error) = async { sd_notify::notify(false, &[NotifyState::Ready])?; loop { sd_notify::notify(false, &[NotifyState::Watchdog])?; tokio::time::sleep(Duration::from_secs(15)).await; } #[allow(unreachable_code)] Ok::<(), anyhow::Error>(()) } .await { error!(?error, "Failed to communicate with systemd"); } }); } drop(config); if protocol_futures.is_empty() { anyhow::bail!("No protocols are enabled in the config file, exiting"); } tokio::spawn(watch_config_and_reload(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)) => { error!(?error, "Server error"); std::process::exit(1); }, None => break, _ => (), } } } } info!("Exiting"); Ok(()) } pub async fn watch_config_and_reload(services: Services) -> Result<()> { let mut reload_event = watch_config(&services.global_params, services.config.clone())?; while let Ok(()) = reload_event.recv().await { let state = services.state.lock().await; let mut cp = services.config_provider.lock().await; // TODO no longer happens since everything is in the DB for (id, session) in state.sessions.iter() { let mut session = session.lock().await; if let (Some(user_info), Some(target)) = (session.user_info.as_ref(), session.target.as_ref()) { if !cp .authorize_target(&user_info.username, &target.name) .await? { warn!(sesson_id=%id, %user_info.username, target=&target.name, "Session no longer authorized after config reload"); session.handle.close(); } } } } Ok(()) } ================================================ FILE: warpgate/src/commands/setup.rs ================================================ #![allow(clippy::collapsible_else_if)] use std::fs::{create_dir_all, File}; use std::io::Write; use std::net::{Ipv6Addr, SocketAddr, ToSocketAddrs}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use dialoguer::theme::ColorfulTheme; use rcgen::generate_simple_self_signed; use tracing::*; use warpgate_common::helpers::fs::{secure_directory, secure_file}; use warpgate_common::version::warpgate_version; use warpgate_common::{ GlobalParams, HttpConfig, KubernetesConfig, ListenEndpoint, MySqlConfig, PostgresConfig, Secret, SshConfig, WarpgateConfigStore, }; use warpgate_core::consts::{BUILTIN_ADMIN_ROLE_NAME, BUILTIN_ADMIN_USERNAME}; use crate::commands::common::{assert_interactive_terminal, is_docker}; use crate::config::load_config; use crate::{Cli, Commands}; fn prompt_endpoint(prompt: &str, default: ListenEndpoint) -> ListenEndpoint { loop { let v = dialoguer::Input::with_theme(&ColorfulTheme::default()) .default(format!("{default:?}")) .with_prompt(prompt) .interact_text() .context("dialoguer") .and_then(|v| v.to_socket_addrs().context("address resolution")); match v { Ok(mut addr) => match addr.next() { Some(addr) => return ListenEndpoint::from(addr), None => { error!("No endpoints resolved"); } }, Err(err) => { error!("Failed to resolve this endpoint: {err}") } } } } pub(crate) async fn command(cli: &Cli, params: &GlobalParams) -> Result<()> { let version = warpgate_version(); info!("Welcome to Warpgate {version}"); if cli.config.exists() { error!("Config file already exists at {}.", cli.config.display()); error!("To generate a new config file, rename or delete the existing one first."); std::process::exit(1); } if let Commands::Setup { .. } = cli.command { assert_interactive_terminal(); } let mut config_dir = cli.config.parent().unwrap_or_else(|| Path::new(&".")); if config_dir.as_os_str().is_empty() { config_dir = Path::new(&"."); } create_dir_all(config_dir)?; info!("Let's do some basic setup first."); info!( "The new config will be written in {}.", cli.config.display() ); let theme = ColorfulTheme::default(); let mut store = WarpgateConfigStore::default(); // --- if !is_docker() { info!( "* Paths can be either absolute or relative to {}.", config_dir.canonicalize()?.display() ); } // --- let data_path: String = if let Commands::UnattendedSetup { data_path, .. } = &cli.command { data_path.to_owned() } else { #[cfg(target_os = "linux")] let default_data_path = "/var/lib/warpgate".to_string(); #[cfg(target_os = "macos")] let default_data_path = "/usr/local/var/lib/warpgate".to_string(); if is_docker() { "/data".to_owned() } else { dialoguer::Input::with_theme(&theme) .default(default_data_path) .with_prompt("Directory to store app data (up to a few MB) in") .interact_text()? } }; let data_path = config_dir.join(PathBuf::from(&data_path)).canonicalize()?; create_dir_all(&data_path)?; let db_path = data_path.join("db"); create_dir_all(&db_path)?; if params.should_secure_files() { secure_directory(&db_path)?; } store.database_url = Secret::new(match &cli.command { Commands::UnattendedSetup { database_url: Some(url), .. } | Commands::Setup { database_url: Some(url), .. } => url.to_owned(), _ => { let mut db_path = db_path.to_string_lossy().to_string(); if let Some(x) = db_path.strip_suffix("./") { db_path = x.to_string(); } format!("sqlite:{db_path}") } }); if let Commands::UnattendedSetup { http_port, .. } = &cli.command { store.http.listen = ListenEndpoint::from(SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), *http_port)); } else { if !is_docker() { store.http.listen = prompt_endpoint( "Endpoint to listen for HTTP connections on", HttpConfig::default().listen, ); } } if let Commands::UnattendedSetup { ssh_port, .. } = &cli.command { if let Some(ssh_port) = ssh_port { store.ssh.enable = true; store.ssh.listen = ListenEndpoint::from(SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), *ssh_port)); } } else { if is_docker() { store.ssh.enable = true; } else { info!("You will now choose specific protocol listeners to be enabled."); info!(""); info!("NB: Nothing will be exposed by default -"); info!(" you'll choose target hosts in the UI later."); store.ssh.enable = dialoguer::Confirm::with_theme(&theme) .default(true) .with_prompt("Accept SSH connections?") .interact()?; if store.ssh.enable { store.ssh.listen = prompt_endpoint( "Endpoint to listen for SSH connections on", SshConfig::default().listen, ); } } } if let Commands::UnattendedSetup { mysql_port, .. } = &cli.command { if let Some(mysql_port) = mysql_port { store.mysql.enable = true; store.mysql.listen = ListenEndpoint::from(SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), *mysql_port)); } } else { if is_docker() { store.mysql.enable = true; } else { store.mysql.enable = dialoguer::Confirm::with_theme(&theme) .default(true) .with_prompt("Accept MySQL connections?") .interact()?; if store.mysql.enable { store.mysql.listen = prompt_endpoint( "Endpoint to listen for MySQL connections on", MySqlConfig::default().listen, ); } } } if let Commands::UnattendedSetup { postgres_port, .. } = &cli.command { if let Some(postgres_port) = postgres_port { store.postgres.enable = true; store.postgres.listen = ListenEndpoint::from(SocketAddr::new( Ipv6Addr::UNSPECIFIED.into(), *postgres_port, )); } } else { if is_docker() { store.postgres.enable = true; } else { store.postgres.enable = dialoguer::Confirm::with_theme(&theme) .default(true) .with_prompt("Accept PostgreSQL connections?") .interact()?; if store.postgres.enable { store.postgres.listen = prompt_endpoint( "Endpoint to listen for PostgreSQL connections on", PostgresConfig::default().listen, ); } } } if let Commands::UnattendedSetup { kubernetes_port, .. } = &cli.command { if let Some(kubernetes_port) = kubernetes_port { store.kubernetes.enable = true; store.kubernetes.listen = ListenEndpoint::from(SocketAddr::new( Ipv6Addr::UNSPECIFIED.into(), *kubernetes_port, )); } } else { if is_docker() { store.kubernetes.enable = true; } else { store.kubernetes.enable = dialoguer::Confirm::with_theme(&theme) .default(true) .with_prompt("Accept Kubernetes connections?") .interact()?; if store.kubernetes.enable { store.kubernetes.listen = prompt_endpoint( "Endpoint to listen for Kubernetes connections on", KubernetesConfig::default().listen, ); } } } store.http.certificate = data_path .join("tls.certificate.pem") .to_string_lossy() .to_string(); store.http.key = data_path.join("tls.key.pem").to_string_lossy().to_string(); store.mysql.certificate = store.http.certificate.clone(); store.mysql.key = store.http.key.clone(); store.postgres.certificate = store.http.certificate.clone(); store.postgres.key = store.http.key.clone(); store.kubernetes.certificate = store.http.certificate.clone(); store.kubernetes.key = store.http.key.clone(); // --- store.ssh.keys = data_path.join("ssh-keys").to_string_lossy().to_string(); // --- if let Commands::UnattendedSetup { record_sessions, .. } = &cli.command { store.recordings.enable = *record_sessions; } else { store.recordings.enable = dialoguer::Confirm::with_theme(&theme) .default(true) .with_prompt("Do you want to record user sessions?") .interact()?; } store.recordings.path = data_path.join("recordings").to_string_lossy().to_string(); // --- let admin_password = Secret::new( if let Commands::UnattendedSetup { admin_password, .. } = &cli.command { if let Some(admin_password) = admin_password { admin_password.to_owned() } else { if let Ok(admin_password) = std::env::var("WARPGATE_ADMIN_PASSWORD") { admin_password } else { error!( "You must supply the admin password either through the --admin-password option" ); error!("or the WARPGATE_ADMIN_PASSWORD environment variable."); std::process::exit(1); } } } else { dialoguer::Password::with_theme(&theme) .with_prompt("Set a password for the Warpgate admin user") .interact()? }, ); if let Commands::UnattendedSetup { external_host, .. } = &cli.command { store.external_host = external_host.clone(); } // --- info!("Generated configuration:"); let yaml = serde_yaml::to_string(&store)?; println!("{yaml}"); let yaml = format!( "# Config generated in version {version}\n# yaml-language-server: $schema=https://raw.githubusercontent.com/warp-tech/warpgate/refs/heads/main/config-schema.json\n\n{yaml}", version = warpgate_version() ); File::create(&cli.config)?.write_all(yaml.as_bytes())?; info!("Saved into {}", cli.config.display()); let config = load_config(params, true)?; warpgate_protocol_ssh::generate_keys(&config, params, "host")?; warpgate_protocol_ssh::generate_keys(&config, params, "client")?; // Create the admin user crate::commands::create_user::command( params, BUILTIN_ADMIN_USERNAME, &admin_password, &Some(BUILTIN_ADMIN_ROLE_NAME.to_string()), ) .await?; { info!("Generating a TLS certificate"); let cert = generate_simple_self_signed(vec![ "warpgate.local".to_string(), "localhost".to_string(), ])?; let certificate_path = params .paths_relative_to() .join(&config.store.http.certificate); let key_path = params.paths_relative_to().join(&config.store.http.key); std::fs::write(&certificate_path, cert.cert.pem())?; std::fs::write(&key_path, cert.key_pair.serialize_pem())?; if params.should_secure_files() { secure_file(&certificate_path)?; secure_file(&key_path)?; } } info!(""); info!("Admin user credentials:"); info!(" * Username: admin"); info!(" * Password: "); info!(""); info!("You can now start Warpgate with:"); if is_docker() { info!("docker run -p 8888:8888 -p 2222:2222 -it -v :/data ghcr.io/warp-tech/warpgate"); } else { info!( " {} --config {} run", std::env::args() .next() .unwrap_or_else(|| "warpgate".to_string()), cli.config.display() ); } Ok(()) } ================================================ FILE: warpgate/src/config.rs ================================================ use std::sync::Arc; use anyhow::{Context, Result}; use config::{Config, Environment, File, FileFormat}; use notify::{recommended_watcher, RecursiveMode, Watcher}; use tokio::sync::{broadcast, mpsc, Mutex}; use tracing::*; use warpgate_common::helpers::fs::secure_file; use warpgate_common::{GlobalParams, WarpgateConfig, WarpgateConfigStore}; pub fn load_config(params: &GlobalParams, secure: bool) -> Result { let mut store: serde_yaml::Value = Config::builder() .add_source(File::new( params .config_path() .to_str() .context("Invalid config path")?, FileFormat::Yaml, )) .add_source(Environment::with_prefix("WARPGATE")) .build() .context("Could not load config")? .try_deserialize() .context("Could not parse YAML")?; if secure && params.should_secure_files() { secure_file(params.config_path()).context("Could not secure config")?; } check_and_migrate_config(&mut store); let store: WarpgateConfigStore = serde_yaml::from_value(store).context("Could not load config")?; let config = WarpgateConfig { store }; info!("Using config: {:?}", params.config_path()); config.validate(); Ok(config) } fn check_and_migrate_config(store: &mut serde_yaml::Value) { use serde_yaml::Value; if let Some(map) = store.as_mapping_mut() { if let Some(web_admin) = map.remove(Value::String("web_admin".into())) { warn!("The `web_admin` config section is deprecated. Rename it to `http`."); map.insert(Value::String("http".into()), web_admin); } if let Some(Value::Sequence(ref mut users)) = map.get_mut(Value::String("users".into())) { for user in users { if let Value::Mapping(ref mut user) = user { if let Some(new_require) = match user.get(Value::String("require".into())) { Some(Value::Sequence(ref old_requires)) => Some(Value::Mapping( vec![ ( Value::String("ssh".into()), Value::Sequence(old_requires.clone()), ), ( Value::String("http".into()), Value::Sequence(old_requires.clone()), ), ] .into_iter() .collect(), )), x => x.cloned(), } { user.insert(Value::String("require".into()), new_require); } } } } } } pub fn watch_config( params: &GlobalParams, config: Arc>, ) -> Result> { let params = params.clone(); let (tx, mut rx) = mpsc::channel(16); let mut watcher = recommended_watcher(move |res| { let _ = tx.blocking_send(res); })?; watcher.watch(params.config_path().as_ref(), RecursiveMode::NonRecursive)?; let (tx2, rx2) = broadcast::channel(16); tokio::spawn(async move { let _watcher = watcher; // avoid dropping the watcher loop { match rx.recv().await { Some(Ok(event)) => { if event.kind.is_modify() { match load_config(¶ms, false) { Ok(new_config) => { *(config.lock().await) = new_config; let _ = tx2.send(()); info!("Reloaded config"); } Err(error) => error!(?error, "Failed to reload config"), } } } Some(Err(error)) => error!(?error, "Failed to watch config"), None => { error!("Config watch failed"); break; } } } #[allow(unreachable_code)] Ok::<_, anyhow::Error>(()) }); Ok(rx2) } ================================================ FILE: warpgate/src/logging.rs ================================================ use std::sync::Arc; use anyhow::{Context, Result}; use time::{format_description, UtcOffset}; use tracing_log::LogTracer; use tracing_subscriber::filter::dynamic_filter_fn; use tracing_subscriber::fmt::time::OffsetTime; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::{EnvFilter, Layer}; use warpgate_common::{LogFormat, WarpgateConfig}; use warpgate_core::logging::{ make_database_logger_layer, make_json_console_logger_layer, make_socket_logger_layer, }; use crate::Cli; pub async fn init_logging(config: Option<&WarpgateConfig>, cli: &Cli) -> Result<()> { if std::env::var("RUST_LOG").is_err() { match cli.debug { 0 => std::env::set_var("RUST_LOG", "warpgate=info"), 1 => std::env::set_var("RUST_LOG", "warpgate=debug"), 2 => std::env::set_var("RUST_LOG", "warpgate=debug,russh=debug"), _ => std::env::set_var("RUST_LOG", "debug"), } } LogTracer::init().context("Failed to initialize log compatibility layer")?; let offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC); let env_filter = Arc::new(EnvFilter::from_default_env()); let enable_colors = console::user_attended(); // Determine effective log format (CLI overrides config) let log_format = cli .log_format .or(config.map(|c| c.store.log.format)) .unwrap_or_default(); let registry = tracing_subscriber::registry(); // #[cfg(all(debug_assertions, feature = "tokio-console"))] // let console_layer = console_subscriber::spawn(); // #[cfg(all(debug_assertions, feature = "tokio-console"))] // let registry = registry.with(console_layer); let socket_layer = match config { Some(config) => Some(make_socket_logger_layer(config).await), None => None, }; // Create JSON console layer (only active when format is JSON) let json_layer = (log_format == LogFormat::Json).then(|| { let env_filter = env_filter.clone(); make_json_console_logger_layer().with_filter(dynamic_filter_fn(move |m, c| { env_filter.enabled(m, c.clone()) })) }); // Create text console layers (only active when format is Text) let text_layer_non_interactive = (log_format == LogFormat::Text && !console::user_attended()) .then({ let env_filter = env_filter.clone(); || { tracing_subscriber::fmt::layer() .with_ansi(enable_colors) .with_timer(OffsetTime::new( offset, #[allow(clippy::unwrap_used)] format_description::parse("[day].[month].[year] [hour]:[minute]:[second]") .unwrap(), )) .with_filter(dynamic_filter_fn(move |m, c| { env_filter.enabled(m, c.clone()) })) } }); let text_layer_interactive = (log_format == LogFormat::Text && console::user_attended()).then(|| { tracing_subscriber::fmt::layer() .compact() .with_ansi(enable_colors) .with_target(false) .with_timer(OffsetTime::new( offset, #[allow(clippy::unwrap_used)] format_description::parse("[hour]:[minute]:[second]").unwrap(), )) .with_filter(dynamic_filter_fn(move |m, c| { env_filter.enabled(m, c.clone()) })) }); let registry = registry .with(json_layer) .with(text_layer_non_interactive) .with(text_layer_interactive); let registry = registry .with(make_database_logger_layer()) .with(socket_layer); registry.init(); Ok(()) } ================================================ FILE: warpgate/src/main.rs ================================================ mod commands; mod config; mod logging; use std::path::PathBuf; use anyhow::Result; use clap::{ArgAction, Parser}; use logging::init_logging; use tracing::*; use warpgate_common::version::warpgate_version; use warpgate_common::{GlobalParams, LogFormat, Secret}; use crate::config::load_config; #[derive(clap::Parser)] #[clap(author, about, long_about = None)] pub struct Cli { #[clap(subcommand)] command: Commands, #[clap(long, short, default_value = "/etc/warpgate.yaml", action=ArgAction::Set, env="WARPGATE_CONFIG")] config: PathBuf, #[clap(long, short, action=ArgAction::Count)] debug: u8, /// Log output format (text or json) #[clap(long, value_enum)] log_format: Option, /// Do not tighten UNIX modes of config and data files #[clap(long)] skip_securing_files: bool, } impl Cli { #[allow(clippy::wrong_self_convention)] pub fn into_global_params(&self) -> anyhow::Result { warpgate_common::GlobalParams::new(self.config.clone(), !self.skip_securing_files) } } #[derive(clap::Subcommand)] pub(crate) enum Commands { /// Run first-time setup and generate a config file Setup { /// Database URL #[clap(long)] database_url: Option, }, /// Run first-time setup non-interactively UnattendedSetup { /// Database URL #[clap(long)] database_url: Option, /// Directory to store data in #[clap(long)] data_path: String, /// HTTP port #[clap(long)] http_port: u16, /// Enable SSH and set port #[clap(long)] ssh_port: Option, /// Enable MySQL and set port #[clap(long)] mysql_port: Option, /// Enable PostgreSQL and set port #[clap(long)] postgres_port: Option, /// Enable Kubernetes and set port #[clap(long)] kubernetes_port: Option, /// Enable session recording #[clap(long)] record_sessions: bool, /// Password for the initial user (required if WARPGATE_ADMIN_PASSWORD env var is not set) #[clap(long)] admin_password: Option, /// External host used to construct URLs (without a port or scheme) #[clap(long)] external_host: Option, }, /// Show Warpgate's SSH client keys ClientKeys, /// Run Warpgate Run { /// Enable an API token (passed via the `WARPGATE_ADMIN_TOKEN` env var) that automatically maps to the first admin user #[clap(long, action=ArgAction::SetTrue)] enable_admin_token: bool, }, /// Perform basic config checks Check, /// Create a new user CreateUser { #[clap(action=ArgAction::Set)] username: String, /// Password (required if WARPGATE_NEW_USER_PASSWORD env var is not set) #[clap(short, long, action=ArgAction::Set)] password: Option, #[clap(short, long, action=ArgAction::Set)] role: Option, }, /// Reset password and auth policy for a user RecoverAccess { #[clap(action=ArgAction::Set)] username: Option, }, /// Show version information Version, /// Automatic healthcheck for running Warpgate in a container Healthcheck, } async fn _main() -> Result<()> { let cli = Cli::parse(); let params = cli.into_global_params()?; init_logging(load_config(¶ms, false).ok().as_ref(), &cli).await?; #[allow(clippy::unwrap_used)] rustls::crypto::aws_lc_rs::default_provider() .install_default() .unwrap(); match &cli.command { Commands::Version => { println!("warpgate {}", warpgate_version()); Ok(()) } Commands::Run { enable_admin_token } => { crate::commands::run::command(¶ms, *enable_admin_token).await } Commands::Check => crate::commands::check::command(¶ms).await, Commands::CreateUser { username, password: explicit_password, role, } => { #[allow(clippy::collapsible_else_if)] let password = if let Some(p) = explicit_password { p.to_owned() } else { if let Ok(p) = std::env::var("WARPGATE_NEW_USER_PASSWORD") { p } else { error!("You must supply the password either through the --password option"); error!("or the WARPGATE_NEW_USER_PASSWORD environment variable."); std::process::exit(1); } }; crate::commands::create_user::command( ¶ms, username, &Secret::new(password.clone()), role, ) .await } Commands::Setup { .. } | Commands::UnattendedSetup { .. } => { crate::commands::setup::command(&cli, ¶ms).await } Commands::ClientKeys => crate::commands::client_keys::command(¶ms).await, Commands::RecoverAccess { username } => { crate::commands::recover_access::command(¶ms, username).await } Commands::Healthcheck => crate::commands::healthcheck::command(¶ms).await, } } #[tokio::main] async fn main() { if let Err(error) = _main().await { error!(?error, "Fatal error"); std::process::exit(1); } } ================================================ FILE: warpgate-admin/Cargo.toml ================================================ [package] edition = "2021" license = "Apache-2.0" name = "warpgate-admin" version = "0.22.0" [dependencies] anyhow.workspace = true async-trait = { version = "0.1", default-features = false } bytes.workspace = true chrono = { version = "0.4", default-features = false } futures.workspace = true hex.workspace = true mime_guess = { version = "2.0", default-features = false } poem.workspace = true poem-openapi.workspace = true rcgen.workspace = true rustls-pki-types.workspace = true russh.workspace = true rust-embed = { version = "8.3", default-features = false } sea-orm.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true tokio.workspace = true tracing.workspace = true uuid.workspace = true warpgate-ca = { version = "*", path = "../warpgate-ca" } warpgate-common = { version = "*", path = "../warpgate-common" } warpgate-common-http = { path = "../warpgate-common-http" } warpgate-tls = { version = "*", path = "../warpgate-tls" } warpgate-core = { version = "*", path = "../warpgate-core" } warpgate-db-entities = { version = "*", path = "../warpgate-db-entities" } warpgate-ldap = { version = "*", path = "../warpgate-ldap" } warpgate-protocol-ssh = { version = "*", path = "../warpgate-protocol-ssh" } warpgate-protocol-kubernetes = { version = "*", path = "../warpgate-protocol-kubernetes" } regex.workspace = true ================================================ FILE: warpgate-admin/src/api/admin_roles.rs ================================================ use poem::web::Data; use poem_openapi::param::{Path, Query}; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, Object, OpenApi}; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, QueryFilter, QueryOrder, Set, }; use uuid::Uuid; use warpgate_common::{AdminPermission, AdminRole as AdminRoleConfig, WarpgateError}; use warpgate_common_http::AuthenticatedRequestContext; use warpgate_core::consts::BUILTIN_ADMIN_ROLE_NAME; use warpgate_db_entities::{AdminRole, User}; use super::AnySecurityScheme; use crate::api::common::require_admin_permission; #[derive(Object)] struct AdminRoleDataRequest { name: String, description: Option, targets_create: bool, targets_edit: bool, targets_delete: bool, users_create: bool, users_edit: bool, users_delete: bool, access_roles_create: bool, access_roles_edit: bool, access_roles_delete: bool, access_roles_assign: bool, sessions_view: bool, sessions_terminate: bool, recordings_view: bool, tickets_create: bool, tickets_delete: bool, config_edit: bool, admin_roles_manage: bool, } #[derive(ApiResponse)] enum GetAdminRolesResponse { #[oai(status = 200)] Ok(Json>), } #[derive(ApiResponse)] enum CreateAdminRoleResponse { #[oai(status = 201)] Created(Json), } #[derive(ApiResponse)] enum GetAdminRoleResponse { #[oai(status = 200)] Ok(Json), #[oai(status = 404)] NotFound, } #[derive(ApiResponse)] enum UpdateAdminRoleResponse { #[oai(status = 200)] Ok(Json), #[oai(status = 404)] NotFound, } #[derive(ApiResponse)] enum DeleteAdminRoleResponse { #[oai(status = 204)] Deleted, #[oai(status = 403)] Forbidden, #[oai(status = 404)] NotFound, } #[derive(ApiResponse)] enum GetAdminRoleUsersResponse { #[oai(status = 200)] Ok(Json>), #[oai(status = 404)] NotFound, } pub struct ListApi; #[OpenApi] impl ListApi { #[oai( path = "/admin-roles", method = "get", operation_id = "get_admin_roles" )] async fn api_get_all_admin_roles( &self, ctx: Data<&AuthenticatedRequestContext>, search: Query>, _sec: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, None).await?; let db = ctx.services.db.lock().await; let mut roles = AdminRole::Entity::find().order_by_asc(AdminRole::Column::Name); if let Some(ref search) = *search { let search = format!("%{search}%"); roles = roles.filter(AdminRole::Column::Name.like(search)); } let roles = roles.all(&*db).await?; Ok(GetAdminRolesResponse::Ok(Json( roles.into_iter().map(Into::into).collect(), ))) } #[oai( path = "/admin-roles", method = "post", operation_id = "create_admin_role" )] async fn api_create_admin_role( &self, ctx: Data<&AuthenticatedRequestContext>, body: Json, _sec: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::AdminRolesManage)).await?; let db = ctx.services.db.lock().await; let values = AdminRole::ActiveModel { id: Set(Uuid::new_v4()), name: Set(body.name.clone()), description: Set(body.description.clone().unwrap_or_default()), targets_create: Set(body.targets_create), targets_edit: Set(body.targets_edit), targets_delete: Set(body.targets_delete), users_create: Set(body.users_create), users_edit: Set(body.users_edit), users_delete: Set(body.users_delete), access_roles_create: Set(body.access_roles_create), access_roles_edit: Set(body.access_roles_edit), access_roles_delete: Set(body.access_roles_delete), access_roles_assign: Set(body.access_roles_assign), sessions_view: Set(body.sessions_view), sessions_terminate: Set(body.sessions_terminate), recordings_view: Set(body.recordings_view), tickets_create: Set(body.tickets_create), tickets_delete: Set(body.tickets_delete), config_edit: Set(body.config_edit), admin_roles_manage: Set(body.admin_roles_manage), }; let role = values.insert(&*db).await?; let role_config: AdminRoleConfig = role.into(); Ok(CreateAdminRoleResponse::Created(Json(role_config))) } } pub struct DetailApi; #[OpenApi] impl DetailApi { #[oai( path = "/admin-roles/:id", method = "get", operation_id = "get_admin_role" )] async fn api_get_admin_role( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, None).await?; let db = ctx.services.db.lock().await; let role = AdminRole::Entity::find_by_id(id.0).one(&*db).await?; Ok(match role { Some(r) => GetAdminRoleResponse::Ok(Json(r.into())), None => GetAdminRoleResponse::NotFound, }) } #[oai( path = "/admin-roles/:id", method = "put", operation_id = "update_admin_role" )] async fn api_update_admin_role( &self, ctx: Data<&AuthenticatedRequestContext>, body: Json, id: Path, _sec: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::AdminRolesManage)).await?; let db = ctx.services.db.lock().await; let Some(role) = AdminRole::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(UpdateAdminRoleResponse::NotFound); }; let mut model: AdminRole::ActiveModel = role.into(); model.name = Set(body.name.clone()); model.description = Set(body.description.clone().unwrap_or_default()); model.targets_create = Set(body.targets_create); model.targets_edit = Set(body.targets_edit); model.targets_delete = Set(body.targets_delete); model.users_create = Set(body.users_create); model.users_edit = Set(body.users_edit); model.users_delete = Set(body.users_delete); model.access_roles_create = Set(body.access_roles_create); model.access_roles_edit = Set(body.access_roles_edit); model.access_roles_delete = Set(body.access_roles_delete); model.access_roles_assign = Set(body.access_roles_assign); model.sessions_view = Set(body.sessions_view); model.sessions_terminate = Set(body.sessions_terminate); model.recordings_view = Set(body.recordings_view); model.tickets_create = Set(body.tickets_create); model.tickets_delete = Set(body.tickets_delete); model.config_edit = Set(body.config_edit); model.admin_roles_manage = Set(body.admin_roles_manage); let role = model.update(&*db).await?; Ok(UpdateAdminRoleResponse::Ok(Json(role.into()))) } #[oai( path = "/admin-roles/:id", method = "delete", operation_id = "delete_admin_role" )] async fn api_delete_admin_role( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::AdminRolesManage)).await?; let db = ctx.services.db.lock().await; let Some(role) = AdminRole::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(DeleteAdminRoleResponse::NotFound); }; // don't allow deleting builtin admin role if role.name == BUILTIN_ADMIN_ROLE_NAME { return Ok(DeleteAdminRoleResponse::Forbidden); } role.delete(&*db).await?; Ok(DeleteAdminRoleResponse::Deleted) } #[oai( path = "/admin-roles/:id/users", method = "get", operation_id = "get_admin_role_users" )] async fn api_get_admin_role_users( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, None).await?; let db = ctx.services.db.lock().await; let Some((_, users)) = AdminRole::Entity::find_by_id(id.0) .find_with_related(User::Entity) .all(&*db) .await .map(|x| x.into_iter().next()) .map_err(WarpgateError::from)? else { return Ok(GetAdminRoleUsersResponse::NotFound); }; let users: Vec = users .into_iter() .map(|x| x.try_into()) .collect::, _>>()?; Ok(GetAdminRoleUsersResponse::Ok(Json(users))) } } ================================================ FILE: warpgate-admin/src/api/certificate_credentials.rs ================================================ use chrono::{DateTime, Utc}; use poem::web::Data; use poem_openapi::param::Path; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, Object, OpenApi}; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, ModelTrait, QueryFilter, Set, }; use uuid::Uuid; use warpgate_ca::{deserialize_certificate, serialize_certificate_serial}; use warpgate_common::{AdminPermission, WarpgateError}; use warpgate_common_http::AuthenticatedRequestContext; use warpgate_db_entities::{CertificateCredential, CertificateRevocation, Parameters, User}; use super::AnySecurityScheme; use crate::api::common::require_admin_permission; fn certificate_fingerprint(certificate_pem: &str) -> Result { Ok(warpgate_ca::certificate_sha256_hex_fingerprint( &warpgate_ca::deserialize_certificate(certificate_pem)?, )?) } #[derive(Object)] struct ExistingCertificateCredential { id: Uuid, label: String, date_added: Option>, last_used: Option>, fingerprint: String, } #[derive(Object)] struct IssuedCertificateCredential { credential: ExistingCertificateCredential, certificate_pem: String, } #[derive(Object)] struct IssueCertificateCredentialRequest { label: String, public_key_pem: String, } #[derive(Object)] struct UpdateCertificateCredential { label: String, } impl From for ExistingCertificateCredential { fn from(credential: CertificateCredential::Model) -> Self { Self { id: credential.id, date_added: credential.date_added, last_used: credential.last_used, label: credential.label, fingerprint: certificate_fingerprint(&credential.certificate_pem) .unwrap_or_else(|_| "Invalid certificate".into()), } } } #[derive(ApiResponse)] enum GetCertificateCredentialsResponse { #[oai(status = 200)] Ok(Json>), } #[derive(ApiResponse)] enum IssueCertificateCredentialResponse { #[oai(status = 201)] Issued(Json), } #[derive(ApiResponse)] enum UpdateCertificateCredentialResponse { #[oai(status = 200)] Updated(Json), #[oai(status = 404)] NotFound, } pub struct ListApi; #[OpenApi] impl ListApi { #[oai( path = "/users/:user_id/credentials/certificates", method = "get", operation_id = "get_certificate_credentials" )] async fn api_get_all( &self, ctx: Data<&AuthenticatedRequestContext>, user_id: Path, _auth: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?; let db = ctx.services.db.lock().await; let objects = CertificateCredential::Entity::find() .filter(CertificateCredential::Column::UserId.eq(*user_id)) .all(&*db) .await?; Ok(GetCertificateCredentialsResponse::Ok(Json( objects.into_iter().map(Into::into).collect(), ))) } #[oai( path = "/users/:user_id/credentials/certificates", method = "post", operation_id = "issue_certificate_credential" )] async fn api_issue( &self, ctx: Data<&AuthenticatedRequestContext>, body: Json, user_id: Path, _auth: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?; let db = ctx.services.db.lock().await; let params = Parameters::Entity::get(&db).await?; let ca = warpgate_ca::deserialize_ca(¶ms.ca_certificate_pem, ¶ms.ca_private_key_pem)?; let user = User::Entity::find_by_id(*user_id) .one(&*db) .await? .ok_or(WarpgateError::UserNotFound(user_id.to_string()))?; let public_key_pem = body.public_key_pem.trim(); let client_cert = warpgate_ca::issue_client_certificate(&ca, &user.username, public_key_pem, *user_id)?; let client_cert_pem = warpgate_ca::certificate_to_pem(&client_cert)?; let object = CertificateCredential::ActiveModel { id: Set(Uuid::new_v4()), user_id: Set(*user_id), date_added: Set(Some(Utc::now())), last_used: Set(None), label: Set(body.label.clone()), certificate_pem: Set(client_cert_pem.clone()), } .insert(&*db) .await .map_err(WarpgateError::from)?; Ok(IssueCertificateCredentialResponse::Issued(Json( IssuedCertificateCredential { credential: object.into(), certificate_pem: client_cert_pem, }, ))) } } #[derive(ApiResponse)] enum RevokeCertificateCredentialResponse { #[oai(status = 204)] Revoked, #[oai(status = 404)] NotFound, } pub struct DetailApi; #[OpenApi] impl DetailApi { #[oai( path = "/users/:user_id/credentials/certificates/:id", method = "patch", operation_id = "update_certificate_credential" )] async fn api_update( &self, ctx: Data<&AuthenticatedRequestContext>, body: Json, user_id: Path, id: Path, _auth: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?; let db = ctx.services.db.lock().await; let Some(cred) = CertificateCredential::Entity::find_by_id(id.0) .filter(CertificateCredential::Column::UserId.eq(*user_id)) .one(&*db) .await? else { return Ok(UpdateCertificateCredentialResponse::NotFound); }; let mut am = cred.into_active_model(); am.label = Set(body.label.clone()); let model = am.update(&*db).await?; Ok(UpdateCertificateCredentialResponse::Updated(Json( model.into(), ))) } #[oai( path = "/users/:user_id/credentials/certificates/:id", method = "delete", operation_id = "revoke_certificate_credential" )] async fn api_delete( &self, ctx: Data<&AuthenticatedRequestContext>, user_id: Path, id: Path, _auth: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?; let db = ctx.services.db.lock().await; let Some(model) = CertificateCredential::Entity::find_by_id(id.0) .filter(CertificateCredential::Column::UserId.eq(*user_id)) .one(&*db) .await? else { return Ok(RevokeCertificateCredentialResponse::NotFound); }; let cert = deserialize_certificate(&model.certificate_pem)?; CertificateRevocation::ActiveModel { id: Set(Uuid::new_v4()), date_added: Set(Utc::now()), serial_number_base64: Set(serialize_certificate_serial(&cert)), } .insert(&*db) .await?; model.delete(&*db).await?; Ok(RevokeCertificateCredentialResponse::Revoked) } } ================================================ FILE: warpgate-admin/src/api/common.rs ================================================ use sea_orm::{ ColumnTrait, EntityTrait, JoinType, PaginatorTrait, QueryFilter, QuerySelect, RelationTrait, }; use warpgate_common::{AdminPermission, WarpgateError}; pub use warpgate_common_http::{RequestAuthorization, SessionAuthorization}; use warpgate_db_entities::{AdminRole, User, UserAdminRoleAssignment}; pub async fn has_admin_permission( ctx: &warpgate_common_http::AuthenticatedRequestContext, specific_permission: Option, ) -> Result { // Admin tokens have all permissions let auth = &ctx.auth; if let RequestAuthorization::AdminToken = auth { return Ok(true); } let username = match auth { RequestAuthorization::Session(SessionAuthorization::User(ref username)) => username, RequestAuthorization::Session(SessionAuthorization::Ticket { ref username, .. }) => { username } RequestAuthorization::UserToken { ref username } => username, RequestAuthorization::AdminToken => unreachable!(), }; let db = ctx.services.db.lock().await; let Some(user_model) = User::Entity::find() .filter(User::Column::Username.eq(username)) .one(&*db) .await? else { return Ok(false); }; let mut query = UserAdminRoleAssignment::Entity::find() .filter(UserAdminRoleAssignment::Column::UserId.eq(user_model.id)) .join( JoinType::InnerJoin, UserAdminRoleAssignment::Relation::AdminRole.def(), ); if let Some(perm) = specific_permission { query = query.filter(match perm { AdminPermission::TargetsCreate => AdminRole::Column::TargetsCreate.eq(true), AdminPermission::TargetsEdit => AdminRole::Column::TargetsEdit.eq(true), AdminPermission::TargetsDelete => AdminRole::Column::TargetsDelete.eq(true), AdminPermission::UsersCreate => AdminRole::Column::UsersCreate.eq(true), AdminPermission::UsersEdit => AdminRole::Column::UsersEdit.eq(true), AdminPermission::UsersDelete => AdminRole::Column::UsersDelete.eq(true), AdminPermission::AccessRolesCreate => AdminRole::Column::AccessRolesCreate.eq(true), AdminPermission::AccessRolesEdit => AdminRole::Column::AccessRolesEdit.eq(true), AdminPermission::AccessRolesDelete => AdminRole::Column::AccessRolesDelete.eq(true), AdminPermission::AccessRolesAssign => AdminRole::Column::AccessRolesAssign.eq(true), AdminPermission::SessionsView => AdminRole::Column::SessionsView.eq(true), AdminPermission::SessionsTerminate => AdminRole::Column::SessionsTerminate.eq(true), AdminPermission::RecordingsView => AdminRole::Column::RecordingsView.eq(true), AdminPermission::TicketsCreate => AdminRole::Column::TicketsCreate.eq(true), AdminPermission::TicketsDelete => AdminRole::Column::TicketsDelete.eq(true), AdminPermission::ConfigEdit => AdminRole::Column::ConfigEdit.eq(true), AdminPermission::AdminRolesManage => AdminRole::Column::AdminRolesManage.eq(true), }); } let count = query.count(&*db).await?; Ok(count > 0) } pub async fn require_admin_permission( ctx: &warpgate_common_http::AuthenticatedRequestContext, specific_permission: Option, ) -> Result<(), WarpgateError> { if has_admin_permission(ctx, specific_permission).await? { Ok(()) } else { Err(match specific_permission { Some(p) => WarpgateError::NoAdminPermission(p), None => WarpgateError::NoAdminAccess, }) } } ================================================ FILE: warpgate-admin/src/api/known_hosts_detail.rs ================================================ use poem::web::Data; use poem_openapi::param::Path; use poem_openapi::{ApiResponse, OpenApi}; use sea_orm::{EntityTrait, ModelTrait}; use uuid::Uuid; use warpgate_common::{AdminPermission, WarpgateError}; use warpgate_common_http::AuthenticatedRequestContext; use warpgate_db_entities::KnownHost; use super::AnySecurityScheme; use crate::api::common::require_admin_permission; 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, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::ConfigEdit)).await?; let db = ctx.services.db.lock().await; let known_host = KnownHost::Entity::find_by_id(id.0).one(&*db).await?; match known_host { Some(known_host) => { known_host.delete(&*db).await?; Ok(DeleteSSHKnownHostResponse::Deleted) } None => Ok(DeleteSSHKnownHostResponse::NotFound), } } } ================================================ FILE: warpgate-admin/src/api/known_hosts_list.rs ================================================ use std::str::FromStr; use anyhow::Context; use poem::web::Data; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, Object, OpenApi}; use russh::keys::{Algorithm, PublicKey}; use sea_orm::{ActiveModelTrait, EntityTrait, Set}; use uuid::Uuid; use warpgate_common::{AdminPermission, WarpgateError}; use warpgate_common_http::AuthenticatedRequestContext; use warpgate_db_entities::KnownHost; use super::AnySecurityScheme; use crate::api::common::require_admin_permission; pub struct Api; #[derive(ApiResponse)] enum GetSSHKnownHostsResponse { #[oai(status = 200)] Ok(Json>), } #[derive(ApiResponse)] enum AddSshKnownHostResponse { #[oai(status = 200)] Ok(Json), } #[derive(Object)] struct AddSshKnownHostRequest { host: String, port: i32, key_type: String, key_base64: String, } #[OpenApi] impl Api { #[oai( path = "/ssh/known-hosts", method = "post", operation_id = "add_ssh_known_host" )] async fn add_ssh_known_host( &self, ctx: Data<&AuthenticatedRequestContext>, body: Json, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::ConfigEdit)).await?; // Validate Algorithm::from_str(&body.key_type).context("parsing key type")?; PublicKey::from_openssh(&format!("{} {}", body.key_type, body.key_base64)) .context("parsing key")?; let db = ctx.services.db.lock().await; let model = KnownHost::ActiveModel { id: Set(Uuid::new_v4()), host: Set(body.host.clone()), port: Set(body.port), key_type: Set(body.key_type.clone()), key_base64: Set(body.key_base64.clone()), } .insert(&*db) .await?; Ok(AddSshKnownHostResponse::Ok(Json(model))) } #[oai( path = "/ssh/known-hosts", method = "get", operation_id = "get_ssh_known_hosts" )] async fn get_ssh_known_hosts( &self, ctx: Data<&AuthenticatedRequestContext>, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::ConfigEdit)).await?; let db = ctx.services.db.lock().await; let hosts = KnownHost::Entity::find().all(&*db).await?; Ok(GetSSHKnownHostsResponse::Ok(Json(hosts))) } } ================================================ FILE: warpgate-admin/src/api/ldap_servers.rs ================================================ use poem::web::Data; use poem_openapi::param::{Path, Query}; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, Object, OpenApi}; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, QueryFilter, QueryOrder, Set, }; use uuid::Uuid; use warpgate_common::{AdminPermission, Secret, WarpgateError}; use warpgate_common_http::AuthenticatedRequestContext; use warpgate_db_entities::LdapServer; use warpgate_ldap::LdapUsernameAttribute; use warpgate_tls::TlsMode; use super::AnySecurityScheme; use crate::api::common::require_admin_permission; #[derive(Object)] struct ImportLdapUsersRequest { dns: Vec, } #[derive(ApiResponse)] enum ImportLdapUsersResponse { #[oai(status = 200)] Ok(Json>), // List of imported usernames #[oai(status = 404)] NotFound, } pub struct ImportApi; #[OpenApi] impl ImportApi { #[oai( path = "/ldap-servers/:id/import-users", method = "post", operation_id = "import_ldap_users" )] async fn api_import_ldap_users( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, body: Json, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::UsersCreate)).await?; if !std::env::var("WARPGATE_UNDER_TEST") .unwrap_or_default() .is_empty() { return Ok(ImportLdapUsersResponse::Ok(Json(vec![]))); } let db = ctx.services.db.lock().await; let Some(server) = LdapServer::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(ImportLdapUsersResponse::NotFound); }; let ldap_config = warpgate_ldap::LdapConfig::try_from(&server)?; let all_users = warpgate_ldap::list_users(&ldap_config).await?; let mut imported = Vec::new(); for dn in &body.dns { if let Some(user) = all_users.iter().find(|u| &u.dn == dn) { let existing = warpgate_db_entities::User::Entity::find() .filter(warpgate_db_entities::User::Column::Username.eq(&user.username)) .one(&*db) .await?; if existing.is_none() { let values = warpgate_db_entities::User::ActiveModel { id: Set(Uuid::new_v4()), username: Set(user.username.clone()), credential_policy: Set(serde_json::to_value( warpgate_common::UserRequireCredentialsPolicy::default(), )?), description: Set(user.display_name.clone().unwrap_or_default()), rate_limit_bytes_per_second: Set(None), ldap_object_uuid: Set(Some(user.object_uuid)), ldap_server_id: Set(Some(server.id)), }; values.insert(&*db).await?; imported.push(user.username.clone()); } } } Ok(ImportLdapUsersResponse::Ok(Json(imported))) } } #[derive(Object)] struct LdapServerResponse { id: Uuid, name: String, host: String, port: i32, bind_dn: String, user_filter: String, base_dns: Vec, tls_mode: TlsMode, tls_verify: bool, enabled: bool, auto_link_sso_users: bool, description: String, username_attribute: LdapUsernameAttribute, ssh_key_attribute: String, uuid_attribute: String, } impl From for LdapServerResponse { fn from(model: LdapServer::Model) -> Self { let base_dns: Vec = serde_json::from_value(model.base_dns).unwrap_or_default(); Self { id: model.id, name: model.name, host: model.host, port: model.port, bind_dn: model.bind_dn, user_filter: model.user_filter, base_dns, tls_mode: TlsMode::from(model.tls_mode.as_str()), tls_verify: model.tls_verify, enabled: model.enabled, auto_link_sso_users: model.auto_link_sso_users, description: model.description, username_attribute: model .username_attribute .as_str() .try_into() .unwrap_or(LdapUsernameAttribute::Cn), ssh_key_attribute: model.ssh_key_attribute, uuid_attribute: model.uuid_attribute, } } } #[derive(Object)] struct CreateLdapServerRequest { name: String, host: String, #[oai(default = "default_port")] port: i32, bind_dn: String, bind_password: Secret, #[oai(default = "default_user_filter")] user_filter: String, #[oai(default = "default_tls_mode")] tls_mode: TlsMode, #[oai(default = "default_tls_verify")] tls_verify: bool, #[oai(default = "default_enabled")] enabled: bool, #[oai(default = "default_auto_link_sso_users")] auto_link_sso_users: bool, description: Option, #[oai(default = "default_username_attribute")] username_attribute: LdapUsernameAttribute, #[oai(default = "default_ssh_key_attribute")] ssh_key_attribute: String, #[oai(default = "default_uuid_attribute")] uuid_attribute: String, } fn default_port() -> i32 { 389 } fn default_user_filter() -> String { "(objectClass=person)".to_string() } fn default_tls_mode() -> TlsMode { TlsMode::Preferred } fn default_tls_verify() -> bool { true } fn default_enabled() -> bool { true } fn default_auto_link_sso_users() -> bool { false } fn default_username_attribute() -> LdapUsernameAttribute { LdapUsernameAttribute::Cn } fn default_ssh_key_attribute() -> String { "sshPublicKey".to_string() } fn default_uuid_attribute() -> String { String::new() } #[derive(Object)] struct UpdateLdapServerRequest { name: String, host: String, port: i32, bind_dn: String, bind_password: Option>, user_filter: String, tls_mode: TlsMode, tls_verify: bool, enabled: bool, auto_link_sso_users: bool, description: Option, username_attribute: LdapUsernameAttribute, ssh_key_attribute: String, uuid_attribute: String, } #[derive(Object)] struct TestLdapServerRequest { host: String, port: i32, bind_dn: String, bind_password: Secret, tls_mode: TlsMode, tls_verify: bool, } #[derive(Object)] struct TestLdapServerResponse { success: bool, message: String, base_dns: Option>, } #[derive(Object)] struct LdapUserResponse { username: String, email: Option, display_name: Option, dn: String, } impl From for LdapUserResponse { fn from(user: warpgate_ldap::LdapUser) -> Self { Self { username: user.username, email: user.email, display_name: user.display_name, dn: user.dn, } } } #[derive(ApiResponse)] enum GetLdapServersResponse { #[oai(status = 200)] Ok(Json>), } #[derive(ApiResponse)] enum CreateLdapServerResponse { #[oai(status = 201)] Created(Json), #[oai(status = 409)] Conflict(Json), #[oai(status = 400)] BadRequest(Json), } #[derive(ApiResponse)] #[allow(dead_code)] enum TestLdapServerConnectionResponse { #[oai(status = 200)] Ok(Json), #[oai(status = 400)] BadRequest(Json), } pub struct ListApi; #[OpenApi] impl ListApi { #[oai( path = "/ldap-servers", method = "get", operation_id = "get_ldap_servers" )] async fn api_get_all_ldap_servers( &self, ctx: Data<&AuthenticatedRequestContext>, search: Query>, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::ConfigEdit)).await?; let db = ctx.services.db.lock().await; let mut query = LdapServer::Entity::find().order_by_asc(LdapServer::Column::Name); if let Some(ref search) = *search { let search_pattern = format!("%{search}%"); query = query.filter(LdapServer::Column::Name.like(search_pattern)); } let servers = query.all(&*db).await.map_err(WarpgateError::from)?; Ok(GetLdapServersResponse::Ok(Json( servers.into_iter().map(Into::into).collect(), ))) } #[oai( path = "/ldap-servers", method = "post", operation_id = "create_ldap_server" )] async fn api_create_ldap_server( &self, ctx: Data<&AuthenticatedRequestContext>, body: Json, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::ConfigEdit)).await?; if body.name.is_empty() { return Ok(CreateLdapServerResponse::BadRequest(Json( "Name cannot be empty".into(), ))); } let db = ctx.services.db.lock().await; // Check if name already exists let existing = LdapServer::Entity::find() .filter(LdapServer::Column::Name.eq(&body.name)) .one(&*db) .await?; if existing.is_some() { return Ok(CreateLdapServerResponse::Conflict(Json( "Name already exists".into(), ))); } // Create LDAP config for discovery let ldap_config = warpgate_ldap::LdapConfig { host: body.host.clone(), port: body.port as u16, bind_dn: body.bind_dn.clone(), bind_password: body.bind_password.expose_secret().clone(), tls_mode: body.tls_mode, tls_verify: body.tls_verify, base_dns: vec![], user_filter: body.user_filter.clone(), username_attribute: body.username_attribute, ssh_key_attribute: body.ssh_key_attribute.clone(), uuid_attribute: if body.uuid_attribute.is_empty() { None } else { Some(body.uuid_attribute.clone()) }, }; // Discover base DNs let base_dns = if std::env::var("WARPGATE_UNDER_TEST") .unwrap_or_default() .is_empty() { warpgate_ldap::discover_base_dns(&ldap_config).await? } else { vec![] }; let base_dns_json = serde_json::to_value(&base_dns)?; let values = LdapServer::ActiveModel { id: Set(Uuid::new_v4()), name: Set(body.name.clone()), host: Set(body.host.clone()), port: Set(body.port), bind_dn: Set(body.bind_dn.clone()), bind_password: Set(body.bind_password.expose_secret().clone()), user_filter: Set(body.user_filter.clone()), base_dns: Set(base_dns_json), tls_mode: Set(String::from(body.tls_mode)), tls_verify: Set(body.tls_verify), enabled: Set(body.enabled), auto_link_sso_users: Set(body.auto_link_sso_users), description: Set(body.description.clone().unwrap_or_default()), username_attribute: Set(body.username_attribute.attribute_name().into()), ssh_key_attribute: Set(body.ssh_key_attribute.clone()), uuid_attribute: Set(body.uuid_attribute.clone()), }; let server = values.insert(&*db).await.map_err(WarpgateError::from)?; Ok(CreateLdapServerResponse::Created(Json(server.into()))) } #[oai( path = "/ldap-servers/test", method = "post", operation_id = "test_ldap_server_connection" )] async fn api_test_ldap_server( &self, body: Json, ctx: Data<&AuthenticatedRequestContext>, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::ConfigEdit)).await?; let ldap_config = warpgate_ldap::LdapConfig { host: body.host.clone(), port: body.port as u16, bind_dn: body.bind_dn.clone(), bind_password: body.bind_password.expose_secret().clone(), tls_mode: body.tls_mode, tls_verify: body.tls_verify, base_dns: vec![], user_filter: String::new(), username_attribute: LdapUsernameAttribute::Cn, ssh_key_attribute: "sshPublicKey".to_string(), uuid_attribute: None, }; if std::env::var("WARPGATE_UNDER_TEST") .unwrap_or_default() .is_empty() { match warpgate_ldap::test_connection(&ldap_config).await { Ok(_) => { // Try to discover base DNs let base_dns = warpgate_ldap::discover_base_dns(&ldap_config).await.ok(); Ok(TestLdapServerConnectionResponse::Ok(Json( TestLdapServerResponse { success: true, message: "Connection successful".to_string(), base_dns, }, ))) } Err(e) => Ok(TestLdapServerConnectionResponse::Ok(Json( TestLdapServerResponse { success: false, message: format!("Connection failed: {}", e), base_dns: None, }, ))), } } else { Ok(TestLdapServerConnectionResponse::Ok(Json( TestLdapServerResponse { success: true, message: "Connection successful".to_string(), base_dns: Some(vec![]), }, ))) } } } #[allow(clippy::large_enum_variant)] #[derive(ApiResponse)] enum GetLdapServerResponse { #[oai(status = 200)] Ok(Json), #[oai(status = 404)] NotFound, } #[derive(ApiResponse)] #[allow(dead_code)] enum UpdateLdapServerResponse { #[oai(status = 200)] Ok(Json), #[oai(status = 404)] NotFound, #[oai(status = 400)] BadRequest(Json), } #[derive(ApiResponse)] enum DeleteLdapServerResponse { #[oai(status = 204)] Deleted, #[oai(status = 404)] NotFound, } pub struct DetailApi; #[OpenApi] impl DetailApi { #[oai( path = "/ldap-servers/:id", method = "get", operation_id = "get_ldap_server" )] async fn api_get_ldap_server( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::ConfigEdit)).await?; let db = ctx.services.db.lock().await; let Some(server) = LdapServer::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(GetLdapServerResponse::NotFound); }; Ok(GetLdapServerResponse::Ok(Json(server.into()))) } #[oai( path = "/ldap-servers/:id", method = "put", operation_id = "update_ldap_server" )] async fn api_update_ldap_server( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, body: Json, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::ConfigEdit)).await?; let db = ctx.services.db.lock().await; let Some(server) = LdapServer::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(UpdateLdapServerResponse::NotFound); }; let mut model: LdapServer::ActiveModel = server.into(); // Update fields model.name = Set(body.name.clone()); model.host = Set(body.host.clone()); model.port = Set(body.port); model.bind_dn = Set(body.bind_dn.clone()); if let Some(password) = &body.bind_password { model.bind_password = Set(password.expose_secret().clone()); } model.user_filter = Set(body.user_filter.clone()); model.tls_mode = Set(String::from(body.tls_mode)); model.tls_verify = Set(body.tls_verify); model.enabled = Set(body.enabled); model.auto_link_sso_users = Set(body.auto_link_sso_users); model.description = Set(body.description.clone().unwrap_or_default()); model.username_attribute = Set(body.username_attribute.attribute_name().into()); model.ssh_key_attribute = Set(body.ssh_key_attribute.clone()); model.uuid_attribute = Set(body.uuid_attribute.clone()); // Re-discover base DNs if connection details changed let ldap_config = warpgate_ldap::LdapConfig { host: body.host.clone(), port: body.port as u16, bind_dn: body.bind_dn.clone(), bind_password: body .bind_password .as_ref() .map(|p| p.expose_secret().clone()) .unwrap_or_else(|| model.bind_password.clone().unwrap()), tls_mode: body.tls_mode, tls_verify: body.tls_verify, base_dns: vec![], user_filter: body.user_filter.clone(), username_attribute: body.username_attribute, ssh_key_attribute: body.ssh_key_attribute.clone(), uuid_attribute: None, }; if let Ok(base_dns) = warpgate_ldap::discover_base_dns(&ldap_config).await { model.base_dns = Set(serde_json::to_value(&base_dns)?); } let server = model.update(&*db).await?; Ok(UpdateLdapServerResponse::Ok(Json(server.into()))) } #[oai( path = "/ldap-servers/:id", method = "delete", operation_id = "delete_ldap_server" )] async fn api_delete_ldap_server( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::ConfigEdit)).await?; let db = ctx.services.db.lock().await; let Some(server) = LdapServer::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(DeleteLdapServerResponse::NotFound); }; server.delete(&*db).await.map_err(WarpgateError::from)?; Ok(DeleteLdapServerResponse::Deleted) } } #[derive(ApiResponse)] enum GetLdapUsersResponse { #[oai(status = 200)] Ok(Json>), #[oai(status = 404)] NotFound, #[oai(status = 400)] BadRequest(Json), } pub struct QueryApi; #[OpenApi] impl QueryApi { #[oai( path = "/ldap-servers/:id/users", method = "get", operation_id = "get_ldap_users" )] async fn api_get_ldap_users( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::UsersCreate)).await?; let db = ctx.services.db.lock().await; let Some(server) = LdapServer::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(GetLdapUsersResponse::NotFound); }; if !std::env::var("WARPGATE_UNDER_TEST") .unwrap_or_default() .is_empty() { return Ok(GetLdapUsersResponse::Ok(Json(vec![]))); } let ldap_config = warpgate_ldap::LdapConfig::try_from(&server)?; let users = match warpgate_ldap::list_users(&ldap_config).await { Ok(users) => users, Err(e) => { return Ok(GetLdapUsersResponse::BadRequest(Json(format!( "Failed to query users: {}", e )))) } }; let mut users = users.into_iter().map(Into::into).collect::>(); users.sort_by_key(|u: &LdapUserResponse| u.username.clone()); Ok(GetLdapUsersResponse::Ok(Json(users))) } } ================================================ FILE: warpgate-admin/src/api/logs.rs ================================================ use chrono::{DateTime, Utc}; use poem::web::Data; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, Object, OpenApi}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect}; use uuid::Uuid; use warpgate_common::WarpgateError; use warpgate_common_http::AuthenticatedRequestContext; use warpgate_db_entities::LogEntry; use super::AnySecurityScheme; use crate::api::common::require_admin_permission; pub struct Api; #[derive(ApiResponse)] enum GetLogsResponse { #[oai(status = 200)] Ok(Json>), } #[derive(Object)] struct GetLogsRequest { before: Option>, after: Option>, limit: Option, session_id: Option, username: Option, search: Option, } #[OpenApi] impl Api { #[oai(path = "/logs", method = "post", operation_id = "get_logs")] async fn api_get_all_logs( &self, ctx: Data<&AuthenticatedRequestContext>, body: Json, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, None).await?; use warpgate_db_entities::LogEntry; let db = ctx.services.db.lock().await; let mut q = LogEntry::Entity::find() .order_by_desc(LogEntry::Column::Timestamp) .limit(body.limit.unwrap_or(100)); if let Some(before) = body.before { q = q.filter(LogEntry::Column::Timestamp.lt(before)); } if let Some(after) = body.after { q = q .filter(LogEntry::Column::Timestamp.gt(after)) .order_by_asc(LogEntry::Column::Timestamp); } if let Some(ref session_id) = body.session_id { q = q.filter(LogEntry::Column::SessionId.eq(*session_id)); } if let Some(ref username) = body.username { q = q.filter(LogEntry::Column::SessionId.eq(username.clone())); } if let Some(ref search) = body.search { q = q.filter( LogEntry::Column::Text .contains(search) .or(LogEntry::Column::Username.contains(search)) .or(LogEntry::Column::Values.contains(search)), ); } let logs = q.all(&*db).await?; Ok(GetLogsResponse::Ok(Json(logs))) } } ================================================ FILE: warpgate-admin/src/api/mod.rs ================================================ use poem_openapi::OpenApi; mod admin_roles; mod certificate_credentials; mod common; mod known_hosts_detail; mod known_hosts_list; mod ldap_servers; mod logs; mod otp_credentials; mod pagination; mod parameters; mod password_credentials; mod public_key_credentials; pub mod recordings_detail; mod roles; mod sessions_detail; pub mod sessions_list; mod ssh_connection_test; mod ssh_keys; mod sso_credentials; mod target_groups; mod targets; mod tickets_detail; mod tickets_list; pub mod users; pub use warpgate_common::api::AnySecurityScheme; pub fn get() -> impl OpenApi { // The arrangement of brackets here is simply due to // the limited number of `impl OpenApi for (T1, T2, ...)` overloads // and has no semantic meaning ( ( (sessions_list::Api, sessions_detail::Api), recordings_detail::Api, (roles::ListApi, roles::DetailApi), (admin_roles::ListApi, admin_roles::DetailApi), (tickets_list::Api, tickets_detail::Api), (known_hosts_list::Api, known_hosts_detail::Api), ssh_keys::Api, logs::Api, (targets::ListApi, targets::DetailApi, targets::RolesApi), (target_groups::ListApi, target_groups::DetailApi), (users::ListApi, users::DetailApi, users::RolesApi), ( password_credentials::ListApi, password_credentials::DetailApi, ), ), ( (sso_credentials::ListApi, sso_credentials::DetailApi), ( public_key_credentials::ListApi, public_key_credentials::DetailApi, ), (otp_credentials::ListApi, otp_credentials::DetailApi), ( ldap_servers::ListApi, ldap_servers::DetailApi, ldap_servers::QueryApi, ldap_servers::ImportApi, ), parameters::Api, ssh_connection_test::Api, ), ( certificate_credentials::ListApi, certificate_credentials::DetailApi, ), ) } ================================================ FILE: warpgate-admin/src/api/otp_credentials.rs ================================================ use poem::web::Data; use poem_openapi::param::Path; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, Object, OpenApi}; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, QueryFilter, Set}; use uuid::Uuid; use warpgate_common::{AdminPermission, UserTotpCredential, WarpgateError}; use warpgate_common_http::AuthenticatedRequestContext; use warpgate_db_entities::OtpCredential; use super::AnySecurityScheme; use crate::api::common::require_admin_permission; #[derive(Object)] struct ExistingOtpCredential { id: Uuid, } #[derive(Object)] struct NewOtpCredential { secret_key: Vec, } impl From for ExistingOtpCredential { fn from(credential: OtpCredential::Model) -> Self { Self { id: credential.id } } } impl From<&NewOtpCredential> for UserTotpCredential { fn from(credential: &NewOtpCredential) -> Self { Self { key: credential.secret_key.clone().into(), } } } #[derive(ApiResponse)] enum GetOtpCredentialsResponse { #[oai(status = 200)] Ok(Json>), } #[derive(ApiResponse)] enum CreateOtpCredentialResponse { #[oai(status = 201)] Created(Json), } pub struct ListApi; #[OpenApi] impl ListApi { #[oai( path = "/users/:user_id/credentials/otp", method = "get", operation_id = "get_otp_credentials" )] async fn api_get_all( &self, ctx: Data<&AuthenticatedRequestContext>, user_id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?; let db = ctx.services.db.lock().await; let objects = OtpCredential::Entity::find() .filter(OtpCredential::Column::UserId.eq(*user_id)) .all(&*db) .await?; Ok(GetOtpCredentialsResponse::Ok(Json( objects.into_iter().map(Into::into).collect(), ))) } #[oai( path = "/users/:user_id/credentials/otp", method = "post", operation_id = "create_otp_credential" )] async fn api_create( &self, ctx: Data<&AuthenticatedRequestContext>, body: Json, user_id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?; let db = ctx.services.db.lock().await; let object = OtpCredential::ActiveModel { id: Set(Uuid::new_v4()), user_id: Set(*user_id), ..OtpCredential::ActiveModel::from(UserTotpCredential::from(&*body)) } .insert(&*db) .await .map_err(WarpgateError::from)?; Ok(CreateOtpCredentialResponse::Created(Json(object.into()))) } } #[derive(ApiResponse)] enum DeleteCredentialResponse { #[oai(status = 204)] Deleted, #[oai(status = 404)] NotFound, } pub struct DetailApi; #[OpenApi] impl DetailApi { #[oai( path = "/users/:user_id/credentials/otp/:id", method = "delete", operation_id = "delete_otp_credential" )] async fn api_delete( &self, ctx: Data<&AuthenticatedRequestContext>, user_id: Path, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?; let db = ctx.services.db.lock().await; let Some(role) = OtpCredential::Entity::find_by_id(id.0) .filter(OtpCredential::Column::UserId.eq(*user_id)) .one(&*db) .await? else { return Ok(DeleteCredentialResponse::NotFound); }; role.delete(&*db).await?; Ok(DeleteCredentialResponse::Deleted) } } ================================================ FILE: warpgate-admin/src/api/pagination.rs ================================================ use poem_openapi::types::{ParseFromJSON, ToJSON}; use poem_openapi::Object; use sea_orm::{ConnectionTrait, EntityTrait, FromQueryResult, PaginatorTrait, QuerySelect, Select}; use warpgate_common::WarpgateError; #[derive(Object)] pub struct PaginatedResponse { items: Vec, offset: u64, total: u64, } pub struct PaginationParams { pub offset: Option, pub limit: Option, } impl PaginatedResponse { pub async fn new( query: Select, params: PaginationParams, db: &'_ C, postprocess: P, ) -> Result, WarpgateError> where E: EntityTrait, C: ConnectionTrait, M: FromQueryResult + Sized + Send + Sync + 'static, P: FnMut(E::Model) -> T, { let offset = params.offset.unwrap_or(0); let limit = params.limit.unwrap_or(100); let paginator = query.clone().paginate(db, limit); let total = paginator.num_items().await?; let query = query.offset(offset).limit(limit); let items = query.all(db).await?; let items = items.into_iter().map(postprocess).collect::>(); Ok(PaginatedResponse { items, offset, total, }) } } ================================================ FILE: warpgate-admin/src/api/parameters.rs ================================================ use poem::web::Data; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, Object, OpenApi}; use sea_orm::ActiveValue::NotSet; use sea_orm::{EntityTrait, IntoActiveModel, Set}; use serde::Serialize; use warpgate_common::{AdminPermission, WarpgateError}; use warpgate_common_http::AuthenticatedRequestContext; use warpgate_db_entities::Parameters; use super::AnySecurityScheme; use crate::api::common::require_admin_permission; pub struct Api; #[derive(Serialize, Object)] struct ParameterValues { pub allow_own_credential_management: bool, pub rate_limit_bytes_per_second: Option, pub ssh_client_auth_publickey: bool, pub ssh_client_auth_password: bool, pub ssh_client_auth_keyboard_interactive: bool, pub minimize_password_login: bool, } #[derive(Serialize, Object)] struct ParameterUpdate { pub allow_own_credential_management: bool, pub rate_limit_bytes_per_second: Option, pub ssh_client_auth_publickey: Option, pub ssh_client_auth_password: Option, pub ssh_client_auth_keyboard_interactive: Option, pub minimize_password_login: Option, } #[derive(ApiResponse)] enum GetParametersResponse { #[oai(status = 200)] Ok(Json), } #[derive(ApiResponse)] enum UpdateParametersResponse { #[oai(status = 201)] Done, } #[OpenApi] impl Api { #[oai(path = "/parameters", method = "get", operation_id = "get_parameters")] async fn api_get( &self, ctx: Data<&AuthenticatedRequestContext>, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, None).await?; let db = ctx.services.db.lock().await; let parameters = Parameters::Entity::get(&db).await?; Ok(GetParametersResponse::Ok(Json(ParameterValues { allow_own_credential_management: parameters.allow_own_credential_management, rate_limit_bytes_per_second: parameters.rate_limit_bytes_per_second.map(|x| x as u32), ssh_client_auth_publickey: parameters.ssh_client_auth_publickey, ssh_client_auth_password: parameters.ssh_client_auth_password, ssh_client_auth_keyboard_interactive: parameters.ssh_client_auth_keyboard_interactive, minimize_password_login: parameters.minimize_password_login, }))) } #[oai( path = "/parameters", method = "put", operation_id = "update_parameters" )] async fn api_update_parameters( &self, ctx: Data<&AuthenticatedRequestContext>, body: Json, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::ConfigEdit)).await?; let services = &ctx.services; let db = services.db.lock().await; let mut parameters = Parameters::Entity::get(&db).await?.into_active_model(); parameters.allow_own_credential_management = Set(body.allow_own_credential_management); parameters.rate_limit_bytes_per_second = Set(body.rate_limit_bytes_per_second.map(|x| x as i64)); parameters.ssh_client_auth_publickey = body.ssh_client_auth_publickey.map_or(NotSet, Set); parameters.ssh_client_auth_password = body.ssh_client_auth_password.map_or(NotSet, Set); parameters.ssh_client_auth_keyboard_interactive = body .ssh_client_auth_keyboard_interactive .map_or(NotSet, Set); parameters.minimize_password_login = body.minimize_password_login.map_or(NotSet, Set); Parameters::Entity::update(parameters).exec(&*db).await?; drop(db); services .rate_limiter_registry .lock() .await .apply_new_rate_limits(&mut *services.state.lock().await) .await?; Ok(UpdateParametersResponse::Done) } } ================================================ FILE: warpgate-admin/src/api/password_credentials.rs ================================================ use poem::web::Data; use poem_openapi::param::Path; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, Object, OpenApi}; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, QueryFilter, Set}; use uuid::Uuid; use warpgate_common::{AdminPermission, Secret, UserPasswordCredential, WarpgateError}; use warpgate_common_http::AuthenticatedRequestContext; use warpgate_db_entities::PasswordCredential; use super::AnySecurityScheme; use crate::api::common::require_admin_permission; #[derive(Object)] struct ExistingPasswordCredential { id: Uuid, } #[derive(Object)] struct NewPasswordCredential { password: Secret, } impl From for ExistingPasswordCredential { fn from(credential: PasswordCredential::Model) -> Self { Self { id: credential.id } } } #[derive(ApiResponse)] enum GetPasswordCredentialsResponse { #[oai(status = 200)] Ok(Json>), } #[derive(ApiResponse)] enum CreatePasswordCredentialResponse { #[oai(status = 201)] Created(Json), } pub struct ListApi; #[OpenApi] impl ListApi { #[oai( path = "/users/:user_id/credentials/passwords", method = "get", operation_id = "get_password_credentials" )] async fn api_get_all( &self, ctx: Data<&AuthenticatedRequestContext>, user_id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?; let db = ctx.services.db.lock().await; let objects = PasswordCredential::Entity::find() .filter(PasswordCredential::Column::UserId.eq(*user_id)) .all(&*db) .await?; Ok(GetPasswordCredentialsResponse::Ok(Json( objects.into_iter().map(Into::into).collect(), ))) } #[oai( path = "/users/:user_id/credentials/passwords", method = "post", operation_id = "create_password_credential" )] async fn api_create( &self, ctx: Data<&AuthenticatedRequestContext>, body: Json, user_id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?; let db = ctx.services.db.lock().await; let object = PasswordCredential::ActiveModel { id: Set(Uuid::new_v4()), user_id: Set(*user_id), ..PasswordCredential::ActiveModel::from(UserPasswordCredential::from_password( &body.password, )) } .insert(&*db) .await .map_err(WarpgateError::from)?; Ok(CreatePasswordCredentialResponse::Created(Json( object.into(), ))) } } #[derive(ApiResponse)] enum DeleteCredentialResponse { #[oai(status = 204)] Deleted, #[oai(status = 404)] NotFound, } pub struct DetailApi; #[OpenApi] impl DetailApi { #[oai( path = "/users/:user_id/credentials/passwords/:id", method = "delete", operation_id = "delete_password_credential" )] async fn api_delete( &self, ctx: Data<&AuthenticatedRequestContext>, user_id: Path, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?; let db = ctx.services.db.lock().await; let Some(model) = PasswordCredential::Entity::find_by_id(id.0) .filter(PasswordCredential::Column::UserId.eq(*user_id)) .one(&*db) .await? else { return Ok(DeleteCredentialResponse::NotFound); }; model.delete(&*db).await?; Ok(DeleteCredentialResponse::Deleted) } } ================================================ FILE: warpgate-admin/src/api/public_key_credentials.rs ================================================ use chrono::{DateTime, Utc}; use poem::web::Data; use poem_openapi::param::Path; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, Object, OpenApi}; use sea_orm::{ ActiveModelTrait, ColumnTrait, DatabaseConnection, DbErr, EntityTrait, ModelTrait, QueryFilter, Set, }; use uuid::Uuid; use warpgate_common::{AdminPermission, UserPublicKeyCredential, WarpgateError}; use warpgate_common_http::AuthenticatedRequestContext; use warpgate_db_entities::PublicKeyCredential; use super::AnySecurityScheme; use crate::api::common::require_admin_permission; async fn check_user_ldap_linked( db: &DatabaseConnection, user_id: Uuid, ) -> Result { use warpgate_db_entities::User; let user = User::Entity::find_by_id(user_id) .one(db) .await? .ok_or_else(|| WarpgateError::UserNotFound(user_id.to_string()))?; Ok(user.ldap_server_id.is_some()) } /// Checks if a user is LDAP-linked and returns an error message if they are. /// Returns Ok(()) if the user is not LDAP-linked, or a formatted error string if they are. async fn verify_user_not_ldap_linked(db: &DatabaseConnection, user_id: Uuid) -> Result<(), String> { if check_user_ldap_linked(db, user_id).await.unwrap_or(false) { Err("Cannot manage SSH keys for LDAP-linked users. Keys are synced from LDAP.".to_string()) } else { Ok(()) } } #[derive(Object)] struct ExistingPublicKeyCredential { id: Uuid, label: String, date_added: Option>, last_used: Option>, openssh_public_key: String, } #[derive(Object)] struct NewPublicKeyCredential { label: String, openssh_public_key: String, } impl From for ExistingPublicKeyCredential { fn from(credential: PublicKeyCredential::Model) -> Self { Self { id: credential.id, date_added: credential.date_added, last_used: credential.last_used, label: credential.label, openssh_public_key: credential.openssh_public_key, } } } impl TryFrom<&NewPublicKeyCredential> for UserPublicKeyCredential { type Error = WarpgateError; fn try_from(credential: &NewPublicKeyCredential) -> Result { let mut key = russh::keys::PublicKey::from_openssh(&credential.openssh_public_key) .map_err(russh::keys::Error::from)?; key.set_comment(""); Ok(Self { key: key.to_openssh().map_err(russh::keys::Error::from)?.into(), }) } } #[derive(ApiResponse)] enum GetPublicKeyCredentialsResponse { #[oai(status = 200)] Ok(Json>), } #[derive(ApiResponse)] enum CreatePublicKeyCredentialResponse { #[oai(status = 201)] Created(Json), #[oai(status = 403)] Forbidden(Json), } #[derive(ApiResponse)] enum UpdatePublicKeyCredentialResponse { #[oai(status = 200)] Updated(Json), #[oai(status = 404)] NotFound, #[oai(status = 403)] Forbidden(Json), } pub struct ListApi; #[OpenApi] impl ListApi { #[oai( path = "/users/:user_id/credentials/public-keys", method = "get", operation_id = "get_public_key_credentials" )] async fn api_get_all( &self, ctx: Data<&AuthenticatedRequestContext>, user_id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?; let db = ctx.services.db.lock().await; let objects = PublicKeyCredential::Entity::find() .filter(PublicKeyCredential::Column::UserId.eq(*user_id)) .all(&*db) .await?; Ok(GetPublicKeyCredentialsResponse::Ok(Json( objects.into_iter().map(Into::into).collect(), ))) } #[oai( path = "/users/:user_id/credentials/public-keys", method = "post", operation_id = "create_public_key_credential" )] async fn api_create( &self, ctx: Data<&AuthenticatedRequestContext>, body: Json, user_id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?; let db = ctx.services.db.lock().await; // Check if user is LDAP-linked if let Err(msg) = verify_user_not_ldap_linked(&db, *user_id).await { return Ok(CreatePublicKeyCredentialResponse::Forbidden(Json(msg))); } let object = PublicKeyCredential::ActiveModel { id: Set(Uuid::new_v4()), user_id: Set(*user_id), date_added: Set(Some(Utc::now())), last_used: Set(None), label: Set(body.label.clone()), ..PublicKeyCredential::ActiveModel::from(UserPublicKeyCredential::try_from(&*body)?) } .insert(&*db) .await .map_err(WarpgateError::from)?; Ok(CreatePublicKeyCredentialResponse::Created(Json( object.into(), ))) } } #[derive(ApiResponse)] enum DeleteCredentialResponse { #[oai(status = 204)] Deleted, #[oai(status = 404)] NotFound, #[oai(status = 403)] Forbidden(Json), } pub struct DetailApi; #[OpenApi] impl DetailApi { #[oai( path = "/users/:user_id/credentials/public-keys/:id", method = "put", operation_id = "update_public_key_credential" )] async fn api_update( &self, ctx: Data<&AuthenticatedRequestContext>, body: Json, user_id: Path, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?; let db = ctx.services.db.lock().await; // Check if user is LDAP-linked if let Err(msg) = verify_user_not_ldap_linked(&db, *user_id).await { return Ok(UpdatePublicKeyCredentialResponse::Forbidden(Json(msg))); } let model = PublicKeyCredential::ActiveModel { id: Set(id.0), user_id: Set(*user_id), date_added: Set(Some(Utc::now())), label: Set(body.label.clone()), ..<_>::from(UserPublicKeyCredential::try_from(&*body)?) } .update(&*db) .await; match model { Ok(model) => Ok(UpdatePublicKeyCredentialResponse::Updated(Json( model.into(), ))), Err(DbErr::RecordNotFound(_)) => Ok(UpdatePublicKeyCredentialResponse::NotFound), Err(e) => Err(e.into()), } } #[oai( path = "/users/:user_id/credentials/public-keys/:id", method = "delete", operation_id = "delete_public_key_credential" )] async fn api_delete( &self, ctx: Data<&AuthenticatedRequestContext>, user_id: Path, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?; let db = ctx.services.db.lock().await; // Check if user is LDAP-linked if let Err(msg) = verify_user_not_ldap_linked(&db, *user_id).await { return Ok(DeleteCredentialResponse::Forbidden(Json(msg))); } let Some(model) = PublicKeyCredential::Entity::find_by_id(id.0) .filter(PublicKeyCredential::Column::UserId.eq(*user_id)) .one(&*db) .await? else { return Ok(DeleteCredentialResponse::NotFound); }; model.delete(&*db).await?; Ok(DeleteCredentialResponse::Deleted) } } ================================================ FILE: warpgate-admin/src/api/recordings_detail.rs ================================================ use std::sync::Arc; use anyhow::Context; use bytes::Bytes; use futures::{SinkExt, StreamExt}; use poem::error::{InternalServerError, NotFoundError}; use poem::web::websocket::{Message, WebSocket}; use poem::web::Data; use poem::{handler, IntoResponse}; use poem_openapi::param::Path; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, OpenApi}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use serde_json::json; use tokio::fs::File; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::sync::Mutex; use tracing::*; use uuid::Uuid; use warpgate_common::AdminPermission; use warpgate_common_http::AuthenticatedRequestContext; use warpgate_core::recordings::{AsciiCast, SessionRecordings, TerminalRecordingItem}; use warpgate_db_entities::Recording::{self, RecordingKind}; use warpgate_protocol_kubernetes::recording::{ KubernetesRecordingItem, KubernetesRecordingItemApiObject, }; use super::AnySecurityScheme; use crate::api::common::require_admin_permission; pub struct Api; #[derive(ApiResponse)] enum GetRecordingResponse { #[oai(status = 200)] Ok(Json), #[oai(status = 404)] NotFound, } #[derive(ApiResponse)] enum GetKubernetesRecordingResponse { #[oai(status = 200)] Ok(Json>), } #[OpenApi] impl Api { #[oai( path = "/recordings/:id", method = "get", operation_id = "get_recording" )] async fn api_get_recording( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> poem::Result { require_admin_permission(&ctx, Some(AdminPermission::RecordingsView)).await?; let db = ctx.services.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), } } #[oai( path = "/recordings/:id/kubernetes", method = "get", operation_id = "get_kubernetes_recording" )] async fn api_get_recording_kubernetes( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> poem::Result { require_admin_permission(&ctx, Some(AdminPermission::RecordingsView)).await?; let db = ctx.services.db.lock().await; let recordings = ctx.services.recordings.lock().await; let recording = Recording::Entity::find_by_id(id.0) .filter(Recording::Column::Kind.eq(RecordingKind::Kubernetes)) .one(&*db) .await .map_err(InternalServerError)?; let Some(recording) = recording else { return Err(NotFoundError.into()); }; let path = recordings.path_for(&recording.session_id, &recording.name); let file = File::open(&path).await.map_err(InternalServerError)?; let reader = BufReader::new(file); let mut lines = reader.lines(); let mut content = Vec::new(); while let Some(line) = lines.next_line().await.context("reading recording")? { let item: KubernetesRecordingItem = serde_json::from_str(&line).context("deserializing recording item")?; content.push(KubernetesRecordingItemApiObject::from(item)); } Ok(GetKubernetesRecordingResponse::Ok(Json(content))) } } #[handler] pub async fn api_get_recording_cast( ctx: Data<&AuthenticatedRequestContext>, recordings: Data<&Arc>>, id: poem::web::Path, ) -> poem::Result { require_admin_permission(&ctx, Some(AdminPermission::RecordingsView)).await?; let db = ctx.services.db.lock().await; let recording = Recording::Entity::find_by_id(id.0) .filter(Recording::Column::Kind.eq(RecordingKind::Terminal)) .one(&*db) .await .map_err(InternalServerError)?; let Some(recording) = recording else { return Err(NotFoundError.into()); }; let path = { recordings .lock() .await .path_for(&recording.session_id, &recording.name) }; let mut response = vec![]; //String::new(); let mut last_size = (0, 0); let file = File::open(&path).await.map_err(InternalServerError)?; let reader = BufReader::new(file); let mut lines = reader.lines(); while let Some(line) = lines.next_line().await.map_err(InternalServerError)? { let entry: TerminalRecordingItem = serde_json::from_str(&line[..]).map_err(InternalServerError)?; let asciicast: AsciiCast = entry.into(); response.push(serde_json::to_string(&asciicast).map_err(InternalServerError)?); if let AsciiCast::Header { width, height, .. } = asciicast { last_size = (width, height); } } response.insert( 0, serde_json::to_string(&AsciiCast::Header { time: 0.0, version: 2, width: last_size.0, height: last_size.1, title: recording.name, }) .map_err(InternalServerError)?, ); Ok(response.join("\n")) } #[handler] pub async fn api_get_recording_tcpdump( ctx: Data<&AuthenticatedRequestContext>, recordings: Data<&Arc>>, id: poem::web::Path, ) -> poem::Result { require_admin_permission(&ctx, Some(AdminPermission::RecordingsView)).await?; let db = ctx.services.db.lock().await; let recording = Recording::Entity::find_by_id(id.0) .filter(Recording::Column::Kind.eq(RecordingKind::Traffic)) .one(&*db) .await .map_err(InternalServerError)?; let Some(recording) = recording else { 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)) } #[handler] pub async fn api_get_recording_stream( ws: WebSocket, recordings: Data<&Arc>>, id: poem::web::Path, ) -> impl IntoResponse { let recordings = recordings.lock().await; let receiver = recordings.subscribe_live(&id).await; ws.on_upgrade(|socket| async move { let (mut sink, _) = socket.split(); sink.send(Message::Text(serde_json::to_string(&json!({ "start": true, "live": receiver.is_some(), }))?)) .await?; if let Some(mut receiver) = receiver { tokio::spawn(async move { if let Err(error) = async { while let Ok(data) = receiver.recv().await { let content: TerminalRecordingItem = serde_json::from_slice(&data)?; let cast: AsciiCast = content.into(); let msg = serde_json::to_string(&json!({ "data": cast }))?; sink.send(Message::Text(msg)).await?; } sink.send(Message::Text(serde_json::to_string(&json!({ "end": true, }))?)) .await?; Ok::<(), anyhow::Error>(()) } .await { error!(%error, "Livestream error:"); } }); } Ok::<(), anyhow::Error>(()) }) } ================================================ FILE: warpgate-admin/src/api/roles.rs ================================================ use poem::web::Data; use poem_openapi::param::{Path, Query}; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, Object, OpenApi}; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, QueryFilter, QueryOrder, Set, }; use uuid::Uuid; use warpgate_common::{ AdminPermission, Role as RoleConfig, Target as TargetConfig, User as UserConfig, WarpgateError, }; use warpgate_common_http::AuthenticatedRequestContext; use warpgate_core::consts::BUILTIN_ADMIN_ROLE_NAME; use warpgate_db_entities::{Role, Target, User}; use super::AnySecurityScheme; use crate::api::common::require_admin_permission; #[derive(Object)] struct RoleDataRequest { name: String, description: Option, } #[derive(ApiResponse)] enum GetRolesResponse { #[oai(status = 200)] Ok(Json>), } #[derive(ApiResponse)] enum CreateRoleResponse { #[oai(status = 201)] Created(Json), #[oai(status = 400)] BadRequest(Json), } pub struct ListApi; #[OpenApi] impl ListApi { #[oai(path = "/roles", method = "get", operation_id = "get_roles")] async fn api_get_all_roles( &self, ctx: Data<&AuthenticatedRequestContext>, search: Query>, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, None).await?; // listing roles is allowed for any administrator let db = ctx.services.db.lock().await; let mut roles = Role::Entity::find().order_by_asc(Role::Column::Name); if let Some(ref search) = *search { let search = format!("%{search}%"); roles = roles.filter(Role::Column::Name.like(search)); } let roles = roles.all(&*db).await?; Ok(GetRolesResponse::Ok(Json( roles.into_iter().map(Into::into).collect(), ))) } #[oai(path = "/roles", method = "post", operation_id = "create_role")] async fn api_create_role( &self, ctx: Data<&AuthenticatedRequestContext>, body: Json, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::AccessRolesCreate)).await?; use warpgate_db_entities::Role; if body.name.is_empty() { return Ok(CreateRoleResponse::BadRequest(Json("name".into()))); } let db = ctx.services.db.lock().await; let values = Role::ActiveModel { id: Set(Uuid::new_v4()), name: Set(body.name.clone()), description: Set(body.description.clone().unwrap_or_default()), }; let role = values.insert(&*db).await.map_err(WarpgateError::from)?; Ok(CreateRoleResponse::Created(Json(role.into()))) } } #[derive(ApiResponse)] enum GetRoleResponse { #[oai(status = 200)] Ok(Json), #[oai(status = 404)] NotFound, } #[derive(ApiResponse)] enum UpdateRoleResponse { #[oai(status = 200)] Ok(Json), #[oai(status = 403)] Forbidden, #[oai(status = 404)] NotFound, } #[derive(ApiResponse)] enum DeleteRoleResponse { #[oai(status = 204)] Deleted, #[oai(status = 403)] Forbidden, #[oai(status = 404)] NotFound, } #[derive(ApiResponse)] enum GetRoleTargetsResponse { #[oai(status = 200)] Ok(Json>), #[oai(status = 404)] NotFound, } #[derive(ApiResponse)] enum GetRoleUsersResponse { #[oai(status = 200)] Ok(Json>), #[oai(status = 404)] NotFound, } pub struct DetailApi; #[OpenApi] impl DetailApi { #[oai(path = "/role/:id", method = "get", operation_id = "get_role")] async fn api_get_role( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, None).await?; let db = ctx.services.db.lock().await; let role = Role::Entity::find_by_id(id.0).one(&*db).await?; Ok(match role { Some(role) => GetRoleResponse::Ok(Json(role.into())), None => GetRoleResponse::NotFound, }) } #[oai(path = "/role/:id", method = "put", operation_id = "update_role")] async fn api_update_role( &self, ctx: Data<&AuthenticatedRequestContext>, body: Json, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::AccessRolesEdit)).await?; let db = ctx.services.db.lock().await; let Some(role) = Role::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(UpdateRoleResponse::NotFound); }; if role.name == BUILTIN_ADMIN_ROLE_NAME { return Ok(UpdateRoleResponse::Forbidden); } let mut model: Role::ActiveModel = role.into(); model.name = Set(body.name.clone()); model.description = Set(body.description.clone().unwrap_or_default()); let role = model.update(&*db).await?; Ok(UpdateRoleResponse::Ok(Json(role.into()))) } #[oai(path = "/role/:id", method = "delete", operation_id = "delete_role")] async fn api_delete_role( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::AccessRolesDelete)).await?; let db = ctx.services.db.lock().await; let Some(role) = Role::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(DeleteRoleResponse::NotFound); }; if role.name == BUILTIN_ADMIN_ROLE_NAME { return Ok(DeleteRoleResponse::Forbidden); } role.delete(&*db).await?; Ok(DeleteRoleResponse::Deleted) } #[oai( path = "/role/:id/targets", method = "get", operation_id = "get_role_targets" )] async fn api_get_role_targets( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, None).await?; let db = ctx.services.db.lock().await; let Some(role) = Role::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(GetRoleTargetsResponse::NotFound); }; let targets = role.find_related(Target::Entity).all(&*db).await?; Ok(GetRoleTargetsResponse::Ok(Json( targets .into_iter() .map(TryInto::try_into) .collect::, serde_json::Error>>()?, ))) } #[oai( path = "/role/:id/users", method = "get", operation_id = "get_role_users" )] async fn api_get_role_users( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, None).await?; let db = ctx.services.db.lock().await; let Some(role) = Role::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(GetRoleUsersResponse::NotFound); }; let users = role.find_related(User::Entity).all(&*db).await?; Ok(GetRoleUsersResponse::Ok(Json( users .into_iter() .map(TryInto::try_into) .collect::, WarpgateError>>()?, ))) } } ================================================ FILE: warpgate-admin/src/api/sessions_detail.rs ================================================ use poem::web::Data; use poem_openapi::param::Path; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, OpenApi}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder}; use uuid::Uuid; use warpgate_common::{AdminPermission, WarpgateError}; use warpgate_common_http::AuthenticatedRequestContext; use warpgate_core::SessionSnapshot; use warpgate_db_entities::{Recording, Session}; use super::AnySecurityScheme; use crate::api::common::require_admin_permission; pub struct Api; #[allow(clippy::large_enum_variant)] #[derive(ApiResponse)] enum GetSessionResponse { #[oai(status = 200)] Ok(Json), #[oai(status = 404)] NotFound, } #[derive(ApiResponse)] enum GetSessionRecordingsResponse { #[oai(status = 200)] Ok(Json>), } #[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, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::SessionsView)).await?; let db = ctx.services.db.lock().await; let session = Session::Entity::find_by_id(id.0).one(&*db).await?; match session { Some(session) => Ok(GetSessionResponse::Ok(Json(session.into()))), None => Ok(GetSessionResponse::NotFound), } } #[oai( path = "/sessions/:id/recordings", method = "get", operation_id = "get_session_recordings" )] async fn api_get_session_recordings( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::RecordingsView)).await?; let db = ctx.services.db.lock().await; let recordings: Vec = Recording::Entity::find() .order_by_desc(Recording::Column::Started) .filter(Recording::Column::SessionId.eq(id.0)) .all(&*db) .await?; Ok(GetSessionRecordingsResponse::Ok(Json(recordings))) } #[oai( path = "/sessions/:id/close", method = "post", operation_id = "close_session" )] async fn api_close_session( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::SessionsTerminate)).await?; let state = ctx.services.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) } } } ================================================ FILE: warpgate-admin/src/api/sessions_list.rs ================================================ use futures::{SinkExt, StreamExt}; use poem::session::Session; use poem::web::websocket::{Message, WebSocket}; use poem::web::Data; use poem::{handler, IntoResponse}; use poem_openapi::param::Query; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, OpenApi}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder}; use warpgate_common::{AdminPermission, WarpgateError}; use warpgate_common_http::AuthenticatedRequestContext; use warpgate_core::SessionSnapshot; use super::pagination::{PaginatedResponse, PaginationParams}; use super::AnySecurityScheme; use crate::api::common::require_admin_permission; pub struct Api; #[derive(ApiResponse)] enum GetSessionsResponse { #[oai(status = 200)] Ok(Json>), } #[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, ctx: Data<&AuthenticatedRequestContext>, offset: Query>, limit: Query>, active_only: Query>, logged_in_only: Query>, _sec_scheme: AnySecurityScheme, ) -> poem::Result { require_admin_permission(&ctx, Some(AdminPermission::SessionsView)).await?; use warpgate_db_entities::Session; let db = ctx.services.db.lock().await; let mut q = Session::Entity::find().order_by_desc(Session::Column::Started); if active_only.unwrap_or(false) { q = q.filter(Session::Column::Ended.is_null()); } if logged_in_only.unwrap_or(false) { q = q.filter(Session::Column::Username.is_not_null()); } Ok(GetSessionsResponse::Ok(Json( PaginatedResponse::new( q, PaginationParams { limit: *limit, offset: *offset, }, &*db, Into::into, ) .await?, ))) } #[oai( path = "/sessions", method = "delete", operation_id = "close_all_sessions" )] async fn api_close_all_sessions( &self, ctx: Data<&AuthenticatedRequestContext>, session: &Session, _sec_scheme: AnySecurityScheme, ) -> poem::Result { require_admin_permission(&ctx, Some(AdminPermission::SessionsTerminate)).await?; let state = ctx.services.state.lock().await; for s in state.sessions.values() { let mut session = s.lock().await; session.handle.close(); } session.purge(); Ok(CloseAllSessionsResponse::Ok) } } #[handler] pub async fn api_get_sessions_changes_stream( ctx: Data<&AuthenticatedRequestContext>, ws: WebSocket, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::SessionsView)).await?; let mut receiver = ctx.services.state.lock().await.subscribe(); Ok(ws .on_upgrade(|socket| async move { let (mut sink, _) = socket.split(); while receiver.recv().await.is_ok() { sink.send(Message::Text("".to_string())).await?; } Ok::<(), anyhow::Error>(()) }) .into_response()) } ================================================ FILE: warpgate-admin/src/api/ssh_connection_test.rs ================================================ use poem::web::Data; use poem_openapi::payload::{Json, PlainText}; use poem_openapi::{ApiResponse, Object, OpenApi}; use russh::keys::PublicKeyBase64; use uuid::Uuid; use warpgate_common::{ AdminPermission, SSHTargetAuth, SshTargetPasswordAuth, TargetSSHOptions, WarpgateError, }; use warpgate_common_http::AuthenticatedRequestContext; use warpgate_protocol_ssh::{RCCommand, RCEvent, RemoteClient}; use super::AnySecurityScheme; use crate::api::common::require_admin_permission; pub struct Api; #[derive(Object)] struct CheckSshHostKeyRequest { host: String, port: u16, } #[derive(Object)] struct CheckSshHostKeyResponseBody { remote_key_type: String, remote_key_base64: String, } #[derive(ApiResponse)] enum CheckSshHostKeyResponse { #[oai(status = 200)] Ok(Json), #[oai(status = 500)] Error(PlainText), } #[OpenApi] impl Api { #[oai( path = "/ssh/check-host-key", method = "post", operation_id = "check_ssh_host_key" )] async fn api_ssh_check_host_key( &self, ctx: Data<&AuthenticatedRequestContext>, body: Json, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::TargetsEdit)).await?; let mut handles = RemoteClient::create(Uuid::new_v4(), ctx.services.clone())?; let _ = handles.command_tx.send(( RCCommand::Connect(TargetSSHOptions { host: body.host.clone(), port: body.port, username: "".into(), allow_insecure_algos: None, auth: SSHTargetAuth::Password(SshTargetPasswordAuth { password: "".to_string().into(), }), }), None, )); let fut = async move { let key = loop { match handles.event_rx.recv().await { Some(RCEvent::HostKeyReceived(key)) => break key, Some(RCEvent::ConnectionError(err)) => return Err(anyhow::Error::from(err)), Some(RCEvent::Error(err)) => return Err(err), None => anyhow::bail!("Failed to connect to target"), _ => (), } }; anyhow::Ok(key) }; // Result is matched manually since we need to manually format // the error message with :# to included the nested errors here match fut.await { Ok(key) => Ok(CheckSshHostKeyResponse::Ok(Json( CheckSshHostKeyResponseBody { remote_key_type: key.algorithm().as_str().into(), remote_key_base64: key.public_key_base64(), }, ))), Err(err) => Ok(CheckSshHostKeyResponse::Error(PlainText(format!( "{err:#}" )))), } } } ================================================ FILE: warpgate-admin/src/api/ssh_keys.rs ================================================ use poem::web::Data; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, Object, OpenApi}; use russh::keys::PublicKeyBase64; use serde::Serialize; use warpgate_common::WarpgateError; use warpgate_common_http::AuthenticatedRequestContext; use super::AnySecurityScheme; use crate::api::common::require_admin_permission; 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>), } #[OpenApi] impl Api { #[oai( path = "/ssh/own-keys", method = "get", operation_id = "get_ssh_own_keys" )] async fn api_ssh_get_own_keys( &self, ctx: Data<&AuthenticatedRequestContext>, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, None).await?; let config = ctx.services.config.lock().await; let keys = warpgate_protocol_ssh::load_keys(&config, &ctx.services.global_params, "client")?; let keys = keys .into_iter() .map(|k| SSHKey { kind: k.algorithm().to_string(), public_key_base64: k.public_key_base64(), }) .collect(); Ok(GetSSHOwnKeysResponse::Ok(Json(keys))) } } ================================================ FILE: warpgate-admin/src/api/sso_credentials.rs ================================================ use poem::web::Data; use poem_openapi::param::Path; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, Object, OpenApi}; use sea_orm::{ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, ModelTrait, QueryFilter, Set}; use uuid::Uuid; use warpgate_common::{AdminPermission, UserSsoCredential, WarpgateError}; use warpgate_common_http::AuthenticatedRequestContext; use warpgate_db_entities::SsoCredential; use super::AnySecurityScheme; use crate::api::common::require_admin_permission; #[derive(Object)] struct ExistingSsoCredential { id: Uuid, provider: Option, email: String, } #[derive(Object)] struct NewSsoCredential { provider: Option, email: String, } impl From for ExistingSsoCredential { fn from(credential: SsoCredential::Model) -> Self { Self { id: credential.id, email: credential.email, provider: credential.provider, } } } impl From<&NewSsoCredential> for UserSsoCredential { fn from(credential: &NewSsoCredential) -> Self { Self { email: credential.email.clone(), provider: credential.provider.clone(), } } } #[derive(ApiResponse)] enum GetSsoCredentialsResponse { #[oai(status = 200)] Ok(Json>), } #[derive(ApiResponse)] enum CreateSsoCredentialResponse { #[oai(status = 201)] Created(Json), } #[derive(ApiResponse)] enum UpdateSsoCredentialResponse { #[oai(status = 200)] Updated(Json), #[oai(status = 404)] NotFound, } pub struct ListApi; #[OpenApi] impl ListApi { #[oai( path = "/users/:user_id/credentials/sso", method = "get", operation_id = "get_sso_credentials" )] async fn api_get_all( &self, ctx: Data<&AuthenticatedRequestContext>, user_id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?; let db = ctx.services.db.lock().await; let objects = SsoCredential::Entity::find() .filter(SsoCredential::Column::UserId.eq(*user_id)) .all(&*db) .await?; Ok(GetSsoCredentialsResponse::Ok(Json( objects.into_iter().map(Into::into).collect(), ))) } #[oai( path = "/users/:user_id/credentials/sso", method = "post", operation_id = "create_sso_credential" )] async fn api_create( &self, ctx: Data<&AuthenticatedRequestContext>, body: Json, user_id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?; let db = ctx.services.db.lock().await; let object = SsoCredential::ActiveModel { id: Set(Uuid::new_v4()), user_id: Set(*user_id), ..SsoCredential::ActiveModel::from(UserSsoCredential::from(&*body)) } .insert(&*db) .await .map_err(WarpgateError::from)?; Ok(CreateSsoCredentialResponse::Created(Json(object.into()))) } } #[derive(ApiResponse)] enum DeleteCredentialResponse { #[oai(status = 204)] Deleted, #[oai(status = 404)] NotFound, } pub struct DetailApi; #[OpenApi] impl DetailApi { #[oai( path = "/users/:user_id/credentials/sso/:id", method = "put", operation_id = "update_sso_credential" )] async fn api_update( &self, ctx: Data<&AuthenticatedRequestContext>, body: Json, user_id: Path, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?; let db = ctx.services.db.lock().await; let model = SsoCredential::ActiveModel { id: Set(id.0), user_id: Set(*user_id), ..<_>::from(UserSsoCredential::from(&*body)) } .update(&*db) .await; match model { Ok(model) => Ok(UpdateSsoCredentialResponse::Updated(Json(model.into()))), Err(DbErr::RecordNotFound(_)) => Ok(UpdateSsoCredentialResponse::NotFound), Err(e) => Err(e.into()), } } #[oai( path = "/users/:user_id/credentials/sso/:id", method = "delete", operation_id = "delete_sso_credential" )] async fn api_delete( &self, ctx: Data<&AuthenticatedRequestContext>, user_id: Path, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?; let db = ctx.services.db.lock().await; let Some(role) = SsoCredential::Entity::find_by_id(id.0) .filter(SsoCredential::Column::UserId.eq(*user_id)) .one(&*db) .await? else { return Ok(DeleteCredentialResponse::NotFound); }; role.delete(&*db).await?; Ok(DeleteCredentialResponse::Deleted) } } ================================================ FILE: warpgate-admin/src/api/target_groups.rs ================================================ use poem::web::Data; use poem_openapi::param::Path; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, Object, OpenApi}; use sea_orm::prelude::Expr; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, QueryFilter, QueryOrder, Set, }; use uuid::Uuid; use warpgate_common::{AdminPermission, WarpgateError}; use warpgate_common_http::AuthenticatedRequestContext; use warpgate_db_entities::TargetGroup; use warpgate_db_entities::TargetGroup::BootstrapThemeColor; use super::AnySecurityScheme; use crate::api::common::require_admin_permission; #[derive(Object)] struct TargetGroupDataRequest { name: String, description: Option, color: Option, } #[derive(ApiResponse)] enum GetTargetGroupsResponse { #[oai(status = 200)] Ok(Json>), } #[derive(ApiResponse)] enum CreateTargetGroupResponse { #[oai(status = 201)] Created(Json), #[oai(status = 409)] Conflict(Json), #[oai(status = 400)] BadRequest(Json), } pub struct ListApi; #[OpenApi] impl ListApi { #[oai( path = "/target-groups", method = "get", operation_id = "list_target_groups" )] async fn api_list_target_groups( &self, ctx: Data<&AuthenticatedRequestContext>, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, None).await?; let db = ctx.services.db.lock().await; let groups = TargetGroup::Entity::find() .order_by_asc(TargetGroup::Column::Name) .all(&*db) .await?; Ok(GetTargetGroupsResponse::Ok(Json(groups))) } #[oai( path = "/target-groups", method = "post", operation_id = "create_target_group" )] async fn api_create_target_group( &self, ctx: Data<&AuthenticatedRequestContext>, body: Json, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::TargetsCreate)).await?; if body.name.is_empty() { return Ok(CreateTargetGroupResponse::BadRequest(Json("name".into()))); } let db = ctx.services.db.lock().await; let existing = TargetGroup::Entity::find() .filter(TargetGroup::Column::Name.eq(body.name.clone())) .one(&*db) .await?; if existing.is_some() { return Ok(CreateTargetGroupResponse::Conflict(Json( "Name already exists".into(), ))); } let values = TargetGroup::ActiveModel { id: Set(Uuid::new_v4()), name: Set(body.name.clone()), description: Set(body.description.clone().unwrap_or_default()), color: Set(body.color.clone()), }; let group = values.insert(&*db).await?; Ok(CreateTargetGroupResponse::Created(Json(group))) } } #[derive(ApiResponse)] enum GetTargetGroupResponse { #[oai(status = 200)] Ok(Json), #[oai(status = 404)] NotFound, } #[derive(ApiResponse)] enum UpdateTargetGroupResponse { #[oai(status = 200)] Ok(Json), #[oai(status = 400)] BadRequest, #[oai(status = 404)] NotFound, } #[derive(ApiResponse)] enum DeleteTargetGroupResponse { #[oai(status = 204)] Deleted, #[oai(status = 404)] NotFound, } pub struct DetailApi; #[OpenApi] impl DetailApi { #[oai( path = "/target-groups/:id", method = "get", operation_id = "get_target_group" )] async fn api_get_target_group( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, None).await?; let db = ctx.services.db.lock().await; let group = TargetGroup::Entity::find_by_id(id.0).one(&*db).await?; match group { Some(group) => Ok(GetTargetGroupResponse::Ok(Json(group))), None => Ok(GetTargetGroupResponse::NotFound), } } #[oai( path = "/target-groups/:id", method = "put", operation_id = "update_target_group" )] async fn api_update_target_group( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, body: Json, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::TargetsEdit)).await?; if body.name.is_empty() { return Ok(UpdateTargetGroupResponse::BadRequest); } let db = ctx.services.db.lock().await; let group = TargetGroup::Entity::find_by_id(id.0).one(&*db).await?; let Some(group) = group else { return Ok(UpdateTargetGroupResponse::NotFound); }; // Check if name is already taken by another group let existing = TargetGroup::Entity::find() .filter(TargetGroup::Column::Name.eq(body.name.clone())) .filter(TargetGroup::Column::Id.ne(id.0)) .one(&*db) .await?; if existing.is_some() { return Ok(UpdateTargetGroupResponse::BadRequest); } let mut group: TargetGroup::ActiveModel = group.into(); group.name = Set(body.name.clone()); group.description = Set(body.description.clone().unwrap_or_default()); group.color = Set(body.color.clone()); let group = group.update(&*db).await?; Ok(UpdateTargetGroupResponse::Ok(Json(group))) } #[oai( path = "/target-groups/:id", method = "delete", operation_id = "delete_target_group" )] async fn api_delete_target_group( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::TargetsDelete)).await?; let db = ctx.services.db.lock().await; let group = TargetGroup::Entity::find_by_id(id.0).one(&*db).await?; let Some(group) = group else { return Ok(DeleteTargetGroupResponse::NotFound); }; // First, unassign all targets from this group by setting their group_id to NULL use warpgate_db_entities::Target; Target::Entity::update_many() .col_expr(Target::Column::GroupId, Expr::value(Option::::None)) .filter(Target::Column::GroupId.eq(id.0)) .exec(&*db) .await?; // Then delete the group group.delete(&*db).await?; Ok(DeleteTargetGroupResponse::Deleted) } } ================================================ FILE: warpgate-admin/src/api/targets.rs ================================================ use poem::web::Data; use poem_openapi::param::{Path, Query}; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, Object, OpenApi}; use sea_orm::prelude::Expr; use sea_orm::sea_query::SimpleExpr; use sea_orm::{ ActiveModelTrait, ColumnTrait, Condition, EntityTrait, ModelTrait, QueryFilter, QueryOrder, Set, }; use uuid::Uuid; use warpgate_common::{ AdminPermission, Role as RoleConfig, Target as TargetConfig, TargetOptions, TargetSSHOptions, WarpgateError, }; use warpgate_common_http::AuthenticatedRequestContext; use warpgate_db_entities::Target::TargetKind; use warpgate_db_entities::{KnownHost, Role, Target, TargetRoleAssignment}; use super::AnySecurityScheme; use crate::api::common::require_admin_permission; #[derive(Object)] struct TargetDataRequest { name: String, description: Option, options: TargetOptions, rate_limit_bytes_per_second: Option, group_id: Option, } #[derive(ApiResponse)] enum GetTargetsResponse { #[oai(status = 200)] Ok(Json>), } #[allow(clippy::large_enum_variant)] #[derive(ApiResponse)] enum CreateTargetResponse { #[oai(status = 201)] Created(Json), #[oai(status = 409)] Conflict(Json), #[oai(status = 400)] BadRequest(Json), } pub struct ListApi; #[OpenApi] impl ListApi { #[oai(path = "/targets", method = "get", operation_id = "get_targets")] async fn api_get_all_targets( &self, ctx: Data<&AuthenticatedRequestContext>, search: Query>, group_id: Query>, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, None).await?; let db = ctx.services.db.lock().await; let mut targets = Target::Entity::find(); if let Some(ref search) = *search { let search_pattern = format!("%{}%", search.to_lowercase()); targets = targets .filter( Condition::any() .add(Target::Column::Name.like(&search_pattern)) .add(Target::Column::Description.like(&search_pattern)), ) .order_by_asc({ let case_expr: SimpleExpr = Expr::case( Expr::col((Target::Entity, Target::Column::Name)).like(&search_pattern), 0, ) .finally(1) .into(); case_expr }) .order_by_asc(Target::Column::Name); } else { targets = targets.order_by_asc(Target::Column::Name); } if let Some(group_id) = *group_id { targets = targets.filter(Target::Column::GroupId.eq(group_id)); } let targets = targets.all(&*db).await.map_err(WarpgateError::from)?; let targets: Result, _> = targets.into_iter().map(|t| t.try_into()).collect(); let targets = targets.map_err(WarpgateError::from)?; Ok(GetTargetsResponse::Ok(Json(targets))) } #[oai(path = "/targets", method = "post", operation_id = "create_target")] async fn api_create_target( &self, ctx: Data<&AuthenticatedRequestContext>, body: Json, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::TargetsCreate)).await?; if body.name.is_empty() { return Ok(CreateTargetResponse::BadRequest(Json("name".into()))); } let db = ctx.services.db.lock().await; let existing = Target::Entity::find() .filter(Target::Column::Name.eq(body.name.clone())) .one(&*db) .await?; if existing.is_some() { return Ok(CreateTargetResponse::Conflict(Json( "Name already exists".into(), ))); } let values = Target::ActiveModel { id: Set(Uuid::new_v4()), name: Set(body.name.clone()), description: Set(body.description.clone().unwrap_or_default()), kind: Set((&body.options).into()), options: Set(serde_json::to_value(body.options.clone()).map_err(WarpgateError::from)?), rate_limit_bytes_per_second: Set(None), group_id: Set(body.group_id), }; let target = values.insert(&*db).await.map_err(WarpgateError::from)?; Ok(CreateTargetResponse::Created(Json( target.try_into().map_err(WarpgateError::from)?, ))) } } #[allow(clippy::large_enum_variant)] #[derive(ApiResponse)] enum GetTargetResponse { #[oai(status = 200)] Ok(Json), #[oai(status = 404)] NotFound, } #[allow(clippy::large_enum_variant)] #[derive(ApiResponse)] enum UpdateTargetResponse { #[oai(status = 200)] Ok(Json), #[oai(status = 400)] BadRequest, #[oai(status = 404)] NotFound, } #[derive(ApiResponse)] enum DeleteTargetResponse { #[oai(status = 204)] Deleted, #[oai(status = 404)] NotFound, } #[derive(ApiResponse)] enum TargetKnownSshHostKeysResponse { #[oai(status = 200)] Found(Json>), #[oai(status = 400)] InvalidType, #[oai(status = 404)] NotFound, } pub struct DetailApi; #[OpenApi] impl DetailApi { #[oai(path = "/targets/:id", method = "get", operation_id = "get_target")] async fn api_get_target( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, None).await?; let db = ctx.services.db.lock().await; let Some(target) = Target::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(GetTargetResponse::NotFound); }; Ok(GetTargetResponse::Ok(Json(target.try_into()?))) } #[oai(path = "/targets/:id", method = "put", operation_id = "update_target")] async fn api_update_target( &self, ctx: Data<&AuthenticatedRequestContext>, body: Json, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::TargetsEdit)).await?; let db = ctx.services.db.lock().await; let Some(target) = Target::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(UpdateTargetResponse::NotFound); }; if target.kind != (&body.options).into() { return Ok(UpdateTargetResponse::BadRequest); } let services = &ctx.services; let mut model: Target::ActiveModel = target.into(); model.name = Set(body.name.clone()); model.description = Set(body.description.clone().unwrap_or_default()); model.options = Set(serde_json::to_value(body.options.clone()).map_err(WarpgateError::from)?); model.rate_limit_bytes_per_second = Set(body.rate_limit_bytes_per_second.map(|x| x as i64)); model.group_id = Set(body.group_id); let target = model.update(&*db).await?; drop(db); services .rate_limiter_registry .lock() .await .apply_new_rate_limits(&mut *services.state.lock().await) .await?; Ok(UpdateTargetResponse::Ok(Json( target.try_into().map_err(WarpgateError::from)?, ))) } #[oai( path = "/targets/:id", method = "delete", operation_id = "delete_target" )] async fn api_delete_target( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::TargetsDelete)).await?; let db = ctx.services.db.lock().await; let Some(target) = Target::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(DeleteTargetResponse::NotFound); }; TargetRoleAssignment::Entity::delete_many() .filter(TargetRoleAssignment::Column::TargetId.eq(target.id)) .exec(&*db) .await?; if target.kind == TargetKind::Ssh { let options: TargetOptions = serde_json::from_value(target.options.clone())?; if let TargetOptions::Ssh(ssh_options) = options { use warpgate_db_entities::KnownHost; KnownHost::Entity::delete_many() .filter(KnownHost::Column::Host.eq(&ssh_options.host)) .filter(KnownHost::Column::Port.eq(ssh_options.port as i32)) .exec(&*db) .await?; } } target.delete(&*db).await?; Ok(DeleteTargetResponse::Deleted) } #[oai( path = "/targets/:id/known-ssh-host-keys", method = "get", operation_id = "get_ssh_target_known_ssh_host_keys" )] async fn get_ssh_target_known_ssh_host_keys( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::TargetsEdit)).await?; let db = ctx.services.db.lock().await; let Some(target) = Target::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(TargetKnownSshHostKeysResponse::NotFound); }; let target: TargetConfig = target.try_into()?; let options: TargetSSHOptions = match target.options { TargetOptions::Ssh(x) => x, _ => return Ok(TargetKnownSshHostKeysResponse::InvalidType), }; let known_hosts = KnownHost::Entity::find() .filter( KnownHost::Column::Host .eq(&options.host) .and(KnownHost::Column::Port.eq(options.port)), ) .all(&*db) .await?; Ok(TargetKnownSshHostKeysResponse::Found(Json(known_hosts))) } } #[derive(ApiResponse)] enum GetTargetRolesResponse { #[oai(status = 200)] Ok(Json>), #[oai(status = 404)] NotFound, } #[derive(ApiResponse)] enum AddTargetRoleResponse { #[oai(status = 201)] Created, #[oai(status = 409)] AlreadyExists, } #[derive(ApiResponse)] enum DeleteTargetRoleResponse { #[oai(status = 204)] Deleted, #[oai(status = 404)] NotFound, } pub struct RolesApi; #[OpenApi] impl RolesApi { #[oai( path = "/targets/:id/roles", method = "get", operation_id = "get_target_roles" )] async fn api_get_target_roles( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, None).await?; let db = ctx.services.db.lock().await; let Some((_, roles)) = Target::Entity::find_by_id(*id) .find_with_related(Role::Entity) .all(&*db) .await .map(|x| x.into_iter().next()) .map_err(WarpgateError::from)? else { return Ok(GetTargetRolesResponse::NotFound); }; Ok(GetTargetRolesResponse::Ok(Json( roles.into_iter().map(|x| x.into()).collect(), ))) } #[oai( path = "/targets/:id/roles/:role_id", method = "post", operation_id = "add_target_role" )] async fn api_add_target_role( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, role_id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::AccessRolesAssign)).await?; let db = ctx.services.db.lock().await; if !TargetRoleAssignment::Entity::find() .filter(TargetRoleAssignment::Column::TargetId.eq(id.0)) .filter(TargetRoleAssignment::Column::RoleId.eq(role_id.0)) .all(&*db) .await .map_err(WarpgateError::from)? .is_empty() { return Ok(AddTargetRoleResponse::AlreadyExists); } let values = TargetRoleAssignment::ActiveModel { target_id: Set(id.0), role_id: Set(role_id.0), ..Default::default() }; values.insert(&*db).await.map_err(WarpgateError::from)?; Ok(AddTargetRoleResponse::Created) } #[oai( path = "/targets/:id/roles/:role_id", method = "delete", operation_id = "delete_target_role" )] async fn api_delete_target_role( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, role_id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::AccessRolesAssign)).await?; let db = ctx.services.db.lock().await; let Some(model) = TargetRoleAssignment::Entity::find() .filter(TargetRoleAssignment::Column::TargetId.eq(id.0)) .filter(TargetRoleAssignment::Column::RoleId.eq(role_id.0)) .one(&*db) .await .map_err(WarpgateError::from)? else { return Ok(DeleteTargetRoleResponse::NotFound); }; model.delete(&*db).await.map_err(WarpgateError::from)?; Ok(DeleteTargetRoleResponse::Deleted) } } ================================================ FILE: warpgate-admin/src/api/tickets_detail.rs ================================================ use poem::web::Data; use poem_openapi::param::Path; use poem_openapi::{ApiResponse, OpenApi}; use sea_orm::{EntityTrait, ModelTrait}; use uuid::Uuid; use warpgate_common::{AdminPermission, WarpgateError}; use warpgate_common_http::AuthenticatedRequestContext; use super::AnySecurityScheme; use crate::api::common::require_admin_permission; 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, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::TicketsDelete)).await?; use warpgate_db_entities::Ticket; let db = ctx.services.db.lock().await; let ticket = Ticket::Entity::find_by_id(id.0).one(&*db).await?; match ticket { Some(ticket) => { ticket.delete(&*db).await?; Ok(DeleteTicketResponse::Deleted) } None => Ok(DeleteTicketResponse::NotFound), } } } ================================================ FILE: warpgate-admin/src/api/tickets_list.rs ================================================ use anyhow::Context; use chrono::{DateTime, Utc}; use poem::web::Data; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, Object, OpenApi}; use sea_orm::ActiveValue::Set; use sea_orm::{ActiveModelTrait, EntityTrait}; use uuid::Uuid; use warpgate_common::helpers::hash::generate_ticket_secret; use warpgate_common::{AdminPermission, WarpgateError}; use warpgate_common_http::AuthenticatedRequestContext; use warpgate_db_entities::Ticket; use super::AnySecurityScheme; use crate::api::common::require_admin_permission; pub struct Api; #[derive(ApiResponse)] enum GetTicketsResponse { #[oai(status = 200)] Ok(Json>), } #[derive(Object)] struct CreateTicketRequest { username: String, target_name: String, expiry: Option>, number_of_uses: Option, description: Option, } #[derive(Object)] struct TicketAndSecret { ticket: Ticket::Model, secret: String, } #[derive(ApiResponse)] enum CreateTicketResponse { #[oai(status = 201)] Created(Json), #[oai(status = 400)] BadRequest(Json), } #[OpenApi] impl Api { #[oai(path = "/tickets", method = "get", operation_id = "get_tickets")] async fn api_get_all_tickets( &self, ctx: Data<&AuthenticatedRequestContext>, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, None).await?; use warpgate_db_entities::Ticket; let db = ctx.services.db.lock().await; let tickets = Ticket::Entity::find().all(&*db).await?; Ok(GetTicketsResponse::Ok(Json(tickets))) } #[oai(path = "/tickets", method = "post", operation_id = "create_ticket")] async fn api_create_ticket( &self, ctx: Data<&AuthenticatedRequestContext>, body: Json, _sec_scheme: AnySecurityScheme, ) -> poem::Result { require_admin_permission(&ctx, Some(AdminPermission::TicketsCreate)).await?; 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 = ctx.services.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()), expiry: Set(body.expiry), uses_left: Set(body.number_of_uses), description: Set(body.description.clone().unwrap_or_default()), }; let ticket = values.insert(&*db).await.context("Error saving ticket")?; Ok(CreateTicketResponse::Created(Json(TicketAndSecret { secret: secret.expose_secret().to_string(), ticket, }))) } } ================================================ FILE: warpgate-admin/src/api/users.rs ================================================ use poem::web::Data; use poem_openapi::param::{Path, Query}; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, Object, OpenApi}; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, QueryFilter, QueryOrder, Set, }; use tracing::warn; use uuid::Uuid; use warpgate_common::{ AdminPermission, AdminRole as AdminRoleConfig, Role as RoleConfig, User as UserConfig, UserRequireCredentialsPolicy, WarpgateError, }; use warpgate_common_http::AuthenticatedRequestContext; use warpgate_db_entities::{AdminRole, Role, User, UserRoleAssignment}; use super::AnySecurityScheme; use crate::api::common::require_admin_permission; #[derive(Object)] struct CreateUserRequest { username: String, description: Option, } #[derive(Object)] struct UserDataRequest { username: String, credential_policy: Option, description: Option, rate_limit_bytes_per_second: Option, } #[derive(ApiResponse)] enum GetUsersResponse { #[oai(status = 200)] Ok(Json>), } #[derive(ApiResponse)] enum CreateUserResponse { #[oai(status = 201)] Created(Json), #[oai(status = 400)] BadRequest(Json), } pub struct ListApi; #[OpenApi] impl ListApi { #[oai(path = "/users", method = "get", operation_id = "get_users")] async fn api_get_all_users( &self, ctx: Data<&AuthenticatedRequestContext>, search: Query>, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, None).await?; let db = ctx.services.db.lock().await; let mut users = User::Entity::find().order_by_asc(User::Column::Username); if let Some(ref search) = *search { let search = format!("%{search}%"); users = users.filter(User::Column::Username.like(search)); } let users = users.all(&*db).await.map_err(WarpgateError::from)?; let users: Vec = users .into_iter() .map(UserConfig::try_from) .collect::, _>>()?; Ok(GetUsersResponse::Ok(Json(users))) } #[oai(path = "/users", method = "post", operation_id = "create_user")] async fn api_create_user( &self, ctx: Data<&AuthenticatedRequestContext>, body: Json, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::UsersCreate)).await?; if body.username.is_empty() { return Ok(CreateUserResponse::BadRequest(Json("name".into()))); } let db = ctx.services.db.lock().await; let values = User::ActiveModel { id: Set(Uuid::new_v4()), username: Set(body.username.clone()), credential_policy: Set( serde_json::to_value(UserRequireCredentialsPolicy::default()) .map_err(WarpgateError::from)?, ), description: Set(body.description.clone().unwrap_or_default()), rate_limit_bytes_per_second: Set(None), ldap_server_id: Set(None), ldap_object_uuid: Set(None), }; let user = values.insert(&*db).await.map_err(WarpgateError::from)?; Ok(CreateUserResponse::Created(Json(user.try_into()?))) } } #[derive(ApiResponse)] #[allow(clippy::large_enum_variant)] enum GetUserResponse { #[oai(status = 200)] Ok(Json), #[oai(status = 404)] NotFound, } #[derive(ApiResponse)] #[allow(clippy::large_enum_variant)] enum UpdateUserResponse { #[oai(status = 200)] Ok(Json), #[oai(status = 404)] NotFound, } #[derive(ApiResponse)] enum DeleteUserResponse { #[oai(status = 204)] Deleted, #[oai(status = 404)] NotFound, } #[derive(ApiResponse)] enum UnlinkUserFromLdapResponse { #[oai(status = 200)] Ok(Json), #[oai(status = 404)] NotFound, #[oai(status = 400)] BadRequest(Json), } #[derive(ApiResponse)] enum AutoLinkUserToLdapResponse { #[oai(status = 200)] Ok(Json), #[oai(status = 404)] NotFound, #[oai(status = 400)] BadRequest(Json), } pub struct DetailApi; #[OpenApi] impl DetailApi { #[oai(path = "/users/:id", method = "get", operation_id = "get_user")] async fn api_get_user( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, None).await?; let db = ctx.services.db.lock().await; let Some(user) = User::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(GetUserResponse::NotFound); }; Ok(GetUserResponse::Ok(Json(user.try_into()?))) } #[oai(path = "/users/:id", method = "put", operation_id = "update_user")] async fn api_update_user( &self, ctx: Data<&AuthenticatedRequestContext>, body: Json, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?; let db = ctx.services.db.lock().await; let Some(user) = User::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(UpdateUserResponse::NotFound); }; let mut model: User::ActiveModel = user.into(); model.username = Set(body.username.clone()); model.description = Set(body.description.clone().unwrap_or_default()); model.credential_policy = Set(serde_json::to_value(body.credential_policy.clone()) .map_err(WarpgateError::from)?); model.rate_limit_bytes_per_second = Set(body.rate_limit_bytes_per_second.map(|x| x as i64)); let user = model.update(&*db).await?; drop(db); ctx.services .rate_limiter_registry .lock() .await .apply_new_rate_limits(&mut *ctx.services.state.lock().await) .await?; Ok(UpdateUserResponse::Ok(Json(user.try_into()?))) } #[oai(path = "/users/:id", method = "delete", operation_id = "delete_user")] async fn api_delete_user( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::UsersDelete)).await?; let db = ctx.services.db.lock().await; let Some(user) = User::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(DeleteUserResponse::NotFound); }; UserRoleAssignment::Entity::delete_many() .filter(UserRoleAssignment::Column::UserId.eq(user.id)) .exec(&*db) .await?; user.delete(&*db).await?; Ok(DeleteUserResponse::Deleted) } #[oai( path = "/users/:id/ldap-link/unlink", method = "post", operation_id = "unlink_user_from_ldap" )] async fn api_unlink_user_from_ldap( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?; let db = ctx.services.db.lock().await; let Some(user) = User::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(UnlinkUserFromLdapResponse::NotFound); }; if user.ldap_server_id.is_none() { return Ok(UnlinkUserFromLdapResponse::BadRequest(Json( "User is not linked to LDAP".to_string(), ))); } let mut model: User::ActiveModel = user.into(); model.ldap_server_id = Set(None); model.ldap_object_uuid = Set(None); let user = model.update(&*db).await?; Ok(UnlinkUserFromLdapResponse::Ok(Json(user.try_into()?))) } #[oai( path = "/users/:id/ldap-link/auto-link", method = "post", operation_id = "auto_link_user_to_ldap" )] async fn api_auto_link_user_to_ldap( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?; use warpgate_db_entities::LdapServer; let db = ctx.services.db.lock().await; let Some(user) = User::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(AutoLinkUserToLdapResponse::NotFound); }; if user.ldap_server_id.is_some() { return Ok(AutoLinkUserToLdapResponse::BadRequest(Json( "User is already linked to LDAP".to_string(), ))); } // Get all enabled LDAP servers let ldap_servers: Vec = LdapServer::Entity::find() .filter(LdapServer::Column::Enabled.eq(true)) .all(&*db) .await?; if ldap_servers.is_empty() { return Ok(AutoLinkUserToLdapResponse::BadRequest(Json( "No enabled LDAP servers configured".to_string(), ))); } // Try to find user in LDAP servers using username as email let username = &user.username; let mut ldap_server_id = None; let mut ldap_object_uuid = None; for ldap_server in ldap_servers { let ldap_config = warpgate_ldap::LdapConfig::try_from(&ldap_server)?; match warpgate_ldap::find_user_by_username(&ldap_config, username).await { Ok(Some(ldap_user)) => { ldap_server_id = Some(ldap_server.id); ldap_object_uuid = Some(ldap_user.object_uuid); break; } Ok(None) => continue, Err(e) => { warn!("Error searching for LDAP user in {}: {e}", ldap_server.name); continue; } } } if ldap_server_id.is_none() { return Ok(AutoLinkUserToLdapResponse::BadRequest(Json(format!( "No LDAP user found with username: {username}", )))); } let mut model: User::ActiveModel = user.into(); model.ldap_server_id = Set(ldap_server_id); model.ldap_object_uuid = Set(ldap_object_uuid); let user = model.update(&*db).await?; Ok(AutoLinkUserToLdapResponse::Ok(Json(user.try_into()?))) } } #[derive(ApiResponse)] enum GetUserRolesResponse { #[oai(status = 200)] Ok(Json>), #[oai(status = 404)] NotFound, } #[derive(ApiResponse)] enum AddUserRoleResponse { #[oai(status = 201)] Created, #[oai(status = 409)] AlreadyExists, } #[derive(ApiResponse)] enum DeleteUserRoleResponse { #[oai(status = 204)] Deleted, #[oai(status = 404)] NotFound, } #[derive(ApiResponse)] enum GetUserAdminRolesResponse { #[oai(status = 200)] Ok(Json>), #[oai(status = 404)] NotFound, } #[derive(ApiResponse)] enum AddUserAdminRoleResponse { #[oai(status = 201)] Created, #[oai(status = 409)] AlreadyExists, } #[derive(ApiResponse)] enum DeleteUserAdminRoleResponse { #[oai(status = 204)] Deleted, #[oai(status = 404)] NotFound, } pub struct RolesApi; #[OpenApi] impl RolesApi { #[oai( path = "/users/:id/roles", method = "get", operation_id = "get_user_roles" )] async fn api_get_user_roles( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, None).await?; let db = ctx.services.db.lock().await; let Some((_, roles)) = User::Entity::find_by_id(*id) .find_with_related(Role::Entity) .all(&*db) .await .map(|x| x.into_iter().next()) .map_err(WarpgateError::from)? else { return Ok(GetUserRolesResponse::NotFound); }; Ok(GetUserRolesResponse::Ok(Json( roles.into_iter().map(|x| x.into()).collect(), ))) } #[oai( path = "/users/:id/roles/:role_id", method = "post", operation_id = "add_user_role" )] async fn api_add_user_role( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, role_id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::AccessRolesAssign)).await?; let db = ctx.services.db.lock().await; if !UserRoleAssignment::Entity::find() .filter(UserRoleAssignment::Column::UserId.eq(id.0)) .filter(UserRoleAssignment::Column::RoleId.eq(role_id.0)) .all(&*db) .await .map_err(WarpgateError::from)? .is_empty() { return Ok(AddUserRoleResponse::AlreadyExists); } let values = UserRoleAssignment::ActiveModel { user_id: Set(id.0), role_id: Set(role_id.0), ..Default::default() }; values.insert(&*db).await.map_err(WarpgateError::from)?; Ok(AddUserRoleResponse::Created) } #[oai( path = "/users/:id/roles/:role_id", method = "delete", operation_id = "delete_user_role" )] async fn api_delete_user_role( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, role_id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::AccessRolesAssign)).await?; let db = ctx.services.db.lock().await; let Some(_user) = User::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(DeleteUserRoleResponse::NotFound); }; let Some(_role) = Role::Entity::find_by_id(role_id.0).one(&*db).await? else { return Ok(DeleteUserRoleResponse::NotFound); }; let Some(model) = UserRoleAssignment::Entity::find() .filter(UserRoleAssignment::Column::UserId.eq(id.0)) .filter(UserRoleAssignment::Column::RoleId.eq(role_id.0)) .one(&*db) .await .map_err(WarpgateError::from)? else { return Ok(DeleteUserRoleResponse::NotFound); }; model.delete(&*db).await.map_err(WarpgateError::from)?; Ok(DeleteUserRoleResponse::Deleted) } #[oai( path = "/users/:id/admin-roles", method = "get", operation_id = "get_user_admin_roles" )] async fn api_get_user_admin_roles( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, None).await?; let db = ctx.services.db.lock().await; let Some((_, roles)) = User::Entity::find_by_id(*id) .find_with_related(AdminRole::Entity) .all(&*db) .await .map(|x| x.into_iter().next()) .map_err(WarpgateError::from)? else { return Ok(GetUserAdminRolesResponse::NotFound); }; Ok(GetUserAdminRolesResponse::Ok(Json( roles.into_iter().map(|x| x.into()).collect(), ))) } #[oai( path = "/users/:id/admin-roles/:role_id", method = "post", operation_id = "add_user_admin_role" )] async fn api_add_user_admin_role( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, role_id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::AdminRolesManage)).await?; let db = ctx.services.db.lock().await; if !warpgate_db_entities::UserAdminRoleAssignment::Entity::find() .filter(warpgate_db_entities::UserAdminRoleAssignment::Column::UserId.eq(id.0)) .filter( warpgate_db_entities::UserAdminRoleAssignment::Column::AdminRoleId.eq(role_id.0), ) .all(&*db) .await .map_err(WarpgateError::from)? .is_empty() { return Ok(AddUserAdminRoleResponse::AlreadyExists); } let values = warpgate_db_entities::UserAdminRoleAssignment::ActiveModel { user_id: Set(id.0), admin_role_id: Set(role_id.0), ..Default::default() }; values.insert(&*db).await.map_err(WarpgateError::from)?; Ok(AddUserAdminRoleResponse::Created) } #[oai( path = "/users/:id/admin-roles/:role_id", method = "delete", operation_id = "delete_user_admin_role" )] async fn api_delete_user_admin_role( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, role_id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { require_admin_permission(&ctx, Some(AdminPermission::AdminRolesManage)).await?; let db = ctx.services.db.lock().await; let Some(_user) = User::Entity::find_by_id(id.0).one(&*db).await? else { return Ok(DeleteUserAdminRoleResponse::NotFound); }; let Some(_role) = AdminRole::Entity::find_by_id(role_id.0).one(&*db).await? else { return Ok(DeleteUserAdminRoleResponse::NotFound); }; let Some(model) = warpgate_db_entities::UserAdminRoleAssignment::Entity::find() .filter(warpgate_db_entities::UserAdminRoleAssignment::Column::UserId.eq(id.0)) .filter( warpgate_db_entities::UserAdminRoleAssignment::Column::AdminRoleId.eq(role_id.0), ) .one(&*db) .await .map_err(WarpgateError::from)? else { return Ok(DeleteUserAdminRoleResponse::NotFound); }; model.delete(&*db).await.map_err(WarpgateError::from)?; Ok(DeleteUserAdminRoleResponse::Deleted) } } ================================================ FILE: warpgate-admin/src/lib.rs ================================================ pub mod api; use poem::{IntoEndpoint, Route}; use poem_openapi::OpenApiService; use warpgate_common::version::warpgate_version; pub fn admin_api_app() -> impl IntoEndpoint { let api_service = OpenApiService::new(crate::api::get(), "Warpgate admin API", warpgate_version()) .server("/@warpgate/admin/api"); let ui = api_service.stoplight_elements(); let spec = api_service.spec_endpoint(); Route::new() .nest("", api_service) .nest("/playground", ui) .nest("/openapi.json", spec) .at( "/recordings/:id/cast", crate::api::recordings_detail::api_get_recording_cast, ) .at( "/recordings/:id/stream", crate::api::recordings_detail::api_get_recording_stream, ) .at( "/recordings/:id/tcpdump", crate::api::recordings_detail::api_get_recording_tcpdump, ) .at( "/sessions/changes", crate::api::sessions_list::api_get_sessions_changes_stream, ) } ================================================ FILE: warpgate-admin/src/main.rs ================================================ mod api; use poem_openapi::OpenApiService; use regex::Regex; use warpgate_common::version::warpgate_version; #[allow(clippy::unwrap_used)] pub fn main() { let api_service = OpenApiService::new(api::get(), "Warpgate Web Admin", warpgate_version()) .server("/@warpgate/admin/api"); let spec = api_service.spec(); let re = Regex::new(r"PaginatedResponse<(?P\w+)>").unwrap(); let spec = re.replace_all(&spec, "Paginated$name"); println!("{spec}"); } ================================================ FILE: warpgate-ca/Cargo.toml ================================================ [package] edition = "2021" license = "Apache-2.0" name = "warpgate-ca" version = "0.22.0" [dependencies] bytes.workspace = true thiserror.workspace = true tokio.workspace = true data-encoding.workspace = true tracing.workspace = true rcgen.workspace = true x509-parser.workspace = true x509-cert = { version = "0.2", features = ["builder", "signature"] } aws-lc-rs = "1" der = "0.7" pem = "3.0" spki = "0.7" const-oid = "0.9" uuid.workspace = true hex.workspace = true ================================================ FILE: warpgate-ca/src/error.rs ================================================ use std::error::Error; use x509_parser::error::{PEMError, X509Error}; #[derive(thiserror::Error, Debug)] pub enum CaError { #[error("x509-cert: {0}")] X509Cert(#[from] x509_cert::builder::Error), #[error("aws-lc-rs: {0}")] AwsLcRs(#[from] aws_lc_rs::error::Unspecified), #[error("DER: {0}")] Der(#[from] der::Error), #[error("I/O: {0}")] Io(#[from] std::io::Error), #[error("ASN.1 X509 {0}")] Asn1X509(#[from] x509_parser::asn1_rs::Err), #[error("ASN.1 PEM: {0}")] Asn1Pem(#[from] x509_parser::asn1_rs::Err), #[error("rcgen: {0}")] RcGen(#[from] rcgen::Error), #[error("Invalid key format")] InvalidKeyFormat, #[error("Invalid certificate format")] InvalidCertificateFormat, #[error(transparent)] Other(Box), } ================================================ FILE: warpgate-ca/src/lib.rs ================================================ use std::time::{Duration, SystemTime}; use aws_lc_rs::digest; use aws_lc_rs::error::KeyRejected; use aws_lc_rs::signature::{EcdsaKeyPair, ECDSA_P384_SHA3_384_ASN1_SIGNING}; use data_encoding::BASE64; use der::{Decode, DecodePem, Encode}; use spki::{AlgorithmIdentifier, SubjectPublicKeyInfo}; use uuid::Uuid; use x509_cert::serial_number::SerialNumber; use x509_cert::time::Validity; use x509_cert::{Certificate, TbsCertificate, Version}; use x509_parser::pem::parse_x509_pem; mod error; pub use error::CaError; impl From for CaError { fn from(err: KeyRejected) -> Self { CaError::Other(Box::new(std::io::Error::new( std::io::ErrorKind::InvalidData, format!("Key rejected: {:?}", err), ))) } } /// A certificate and its associated private key #[derive(Debug)] pub struct CertifiedKey { /// The X.509 certificate pub certificate: Certificate, /// The private key in DER format pub private_key_der: Vec, } impl CertifiedKey { /// Get the certificate as PEM-encoded string pub fn certificate_pem(&self) -> Result { let der = self.certificate.to_der()?; let pem_data = pem::Pem::new("CERTIFICATE", der); Ok(pem::encode(&pem_data)) } /// Get the private key as PEM-encoded string pub fn private_key_pem(&self) -> String { let pem_data = pem::Pem::new("EC PRIVATE KEY", self.private_key_der.clone()); pem::encode(&pem_data) } } pub fn generate_root_certificate_rcgen() -> Result<(String, String), CaError> { use rcgen::{CertificateParams, DistinguishedName, IsCa, KeyPair}; // Create a new key pair let key_pair = KeyPair::generate_for(&rcgen::PKCS_ECDSA_P384_SHA384)?; // Create certificate parameters let mut params = CertificateParams::new(vec![])?; // Set up distinguished name let mut dn = DistinguishedName::new(); dn.push(rcgen::DnType::CommonName, "Warpgate Instance CA"); params.distinguished_name = dn; params.is_ca = IsCa::Ca(rcgen::BasicConstraints::Unconstrained); params.not_before = SystemTime::now().into(); params.not_after = (SystemTime::now() + Duration::from_secs(99 * 365 * 24 * 60 * 60)).into(); // Generate the certificate let cert = params.self_signed(&key_pair)?; Ok((cert.pem(), key_pair.serialize_pem())) } pub fn deserialize_certificate(pem: &str) -> Result { let (_, pem_cert) = parse_x509_pem(pem.as_bytes())?; Ok(Certificate::from_der(&pem_cert.contents)?) } pub fn serialize_certificate_serial(cert: &Certificate) -> String { BASE64.encode(cert.tbs_certificate.serial_number.as_bytes()) } pub fn certificate_sha256_hex_fingerprint(cert: &Certificate) -> Result { let der = cert.to_der()?; let digest = aws_lc_rs::digest::digest(&aws_lc_rs::digest::SHA256, &der); Ok(hex::encode(digest.as_ref())) } /// Deserialize a CA certificate and private key from PEM format pub fn deserialize_ca( certificate_pem: &str, private_key_pem: &str, ) -> Result { let certificate = deserialize_certificate(certificate_pem)?; // Parse the private key PEM let key_pem = pem::parse(private_key_pem).map_err(|e| { CaError::Other(Box::new(std::io::Error::new( std::io::ErrorKind::InvalidData, format!("Failed to parse private key PEM: {:?}", e), ))) })?; // Validate it's a private key by checking the tag let tag = key_pem.tag(); let ec_private_key_tag = "PRIVATE KEY"; if tag != ec_private_key_tag { return Err(CaError::InvalidKeyFormat); } Ok(CertifiedKey { certificate, private_key_der: key_pem.contents().to_vec(), }) } /// Issue a client certificate signed by the CA pub fn issue_client_certificate( ca: &CertifiedKey, subject_name: &str, public_key_pem: &str, user_id: Uuid, ) -> Result { use const_oid::db::{rfc4519, rfc5280, rfc5912}; use der::asn1::{OctetString, SetOfVec, Utf8StringRef}; use x509_cert::attr::AttributeTypeAndValue; use x509_cert::ext::pkix::{BasicConstraints, KeyUsage, KeyUsages}; use x509_cert::ext::Extension; use x509_cert::name::{RdnSequence, RelativeDistinguishedName}; let ca_key_pair = EcdsaKeyPair::from_pkcs8(&ECDSA_P384_SHA3_384_ASN1_SIGNING, &ca.private_key_der)?; let validity = { let validity = Duration::from_secs(365 * 24 * 60 * 60); let now = SystemTime::now(); Validity { not_before: now.try_into()?, not_after: (now + validity).try_into()?, } }; let subject = { let name_attrs = vec![AttributeTypeAndValue { oid: rfc4519::UID, value: Utf8StringRef::new(&user_id.to_string())?.into(), }]; let rdn = RelativeDistinguishedName::from(SetOfVec::try_from(name_attrs)?); RdnSequence::from(vec![rdn]) }; // Get the issuer name from the CA certificate let issuer = ca.certificate.tbs_certificate.issuer.clone(); let serial_number = { // Generate a unique serial number let mut hasher = digest::Context::new(&digest::SHA256); hasher.update(subject_name.as_bytes()); hasher.update( &SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap_or_default() .as_nanos() .to_le_bytes(), ); let hash = hasher.finish(); #[allow(clippy::indexing_slicing, reason = "length known")] let serial_bytes = &hash.as_ref()[..8]; // Use first 8 bytes SerialNumber::new(serial_bytes)? }; let public_key = SubjectPublicKeyInfo::from_pem(public_key_pem)?; let tbs_cert = TbsCertificate { version: Version::V3, serial_number, signature: AlgorithmIdentifier { oid: rfc5912::ECDSA_WITH_SHA_384, parameters: None, }, issuer, validity, subject, subject_public_key_info: public_key, issuer_unique_id: None, subject_unique_id: None, extensions: Some(vec![ Extension { extn_id: rfc5280::ID_CE_BASIC_CONSTRAINTS, critical: true, extn_value: OctetString::new( BasicConstraints { ca: false, path_len_constraint: None, } .to_der()?, )?, }, Extension { extn_id: rfc5280::ID_CE_KEY_USAGE, critical: true, extn_value: OctetString::new({ (KeyUsage::from( KeyUsages::DigitalSignature | KeyUsages::KeyEncipherment | KeyUsages::DataEncipherment, )) .to_der()? })?, }, ]), }; let signature = { // Sign the certificate let rng = aws_lc_rs::rand::SystemRandom::new(); let tbs_cert_der = tbs_cert.to_der()?; ca_key_pair.sign(&rng, &tbs_cert_der)? }; let certificate = Certificate { tbs_certificate: tbs_cert, signature_algorithm: AlgorithmIdentifier { oid: rfc5912::ECDSA_WITH_SHA_384, parameters: None, }, signature: der::asn1::BitString::from_bytes(signature.as_ref())?, }; Ok(certificate) } /// Convert a certificate to PEM format pub fn certificate_to_pem(certificate: &Certificate) -> Result { let der = certificate.to_der()?; let pem_data = pem::Pem::new("CERTIFICATE", der); Ok(pem::encode(&pem_data)) } ================================================ FILE: warpgate-common/Cargo.toml ================================================ [package] edition = "2021" license = "Apache-2.0" name = "warpgate-common" version = "0.22.0" [[bin]] name = "config-schema" path = "src/config_schema.rs" [dependencies] anyhow.workspace = true clap = { version = "4.0", features = ["derive", "std"], default-features = false } argon2 = { version = "0.5", default-features = false } async-trait = { version = "0.1", default-features = false } bytes.workspace = true chrono = { version = "0.4", default-features = false, features = ["serde"] } data-encoding.workspace = true delegate.workspace = true futures.workspace = true git-version = { version = "0.3.9", default-features = false } governor.workspace = true humantime-serde = { version = "1.1", default-features = false } once_cell = { version = "1.17", default-features = false } password-hash.workspace = true poem.workspace = true poem-openapi.workspace = true rand_chacha.workspace = true rand_core.workspace = true rand.workspace = true rcgen.workspace = true reqwest.workspace = true reqwest-websocket.workspace = true russh.workspace = true rustls-native-certs = { version = "0.8", default-features = false } rustls-pki-types.workspace = true rustls.workspace = true schemars.workspace = true sea-orm.workspace = true serde_json.workspace = true serde.workspace = true thiserror.workspace = true tokio-rustls.workspace = true tokio-stream.workspace = true tokio-tungstenite.workspace = true tokio.workspace = true totp-rs = { version = "5.0", features = ["otpauth"], default-features = false } tracing-core = { version = "0.1", default-features = false } tracing.workspace = true url = { version = "2.2", default-features = false } uuid.workspace = true warpgate-ca = { version = "*", path = "../warpgate-ca", default-features = false } warpgate-ldap = { version = "*", path = "../warpgate-ldap" } warpgate-sso = { version = "*", path = "../warpgate-sso", default-features = false } warpgate-tls = { version = "*", path = "../warpgate-tls", default-features = false } webpki = { version = "0.22", default-features = false } x509-parser = "0.17.0" ================================================ FILE: warpgate-common/src/api.rs ================================================ use poem_openapi::auth::ApiKey; use poem_openapi::SecurityScheme; #[derive(SecurityScheme)] #[oai(ty = "api_key", key_name = "X-Warpgate-Token", key_in = "header")] #[allow(dead_code)] pub struct TokenSecurityScheme(ApiKey); #[derive(SecurityScheme)] #[oai(ty = "api_key", key_name = "warpgate-http-session", key_in = "cookie")] #[allow(dead_code)] pub struct CookieSecurityScheme(ApiKey); #[derive(SecurityScheme)] #[allow(dead_code)] pub enum AnySecurityScheme { Token(TokenSecurityScheme), Cookie(CookieSecurityScheme), } ================================================ FILE: warpgate-common/src/auth/cred.rs ================================================ use bytes::Bytes; use poem_openapi::Enum; use russh::keys::Algorithm; use serde::{Deserialize, Serialize}; use crate::{Secret, UserCertificateCredential}; #[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Hash, Enum)] pub enum CredentialKind { #[serde(rename = "password")] Password, #[serde(rename = "publickey")] PublicKey, #[serde(rename = "certificate")] Certificate, #[serde(rename = "otp")] Totp, #[serde(rename = "sso")] Sso, #[serde(rename = "web")] WebUserApproval, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum AuthCredential { Otp(Secret), Password(Secret), PublicKey { kind: Algorithm, public_key_bytes: Bytes, }, Certificate { certificate_pem: Secret, }, Sso { provider: String, email: String, }, WebUserApproval, } impl AuthCredential { pub fn kind(&self) -> CredentialKind { match self { Self::Password { .. } => CredentialKind::Password, Self::PublicKey { .. } => CredentialKind::PublicKey, Self::Certificate { .. } => CredentialKind::Certificate, Self::Otp { .. } => CredentialKind::Totp, Self::Sso { .. } => CredentialKind::Sso, Self::WebUserApproval => CredentialKind::WebUserApproval, } } pub fn safe_description(&self) -> String { match self { Self::Password { .. } => "password".to_string(), Self::PublicKey { .. } => "public key".to_string(), Self::Certificate { .. } => "client certificate".to_string(), Self::Otp { .. } => "one-time password".to_string(), Self::Sso { provider, .. } => format!("SSO ({provider})"), Self::WebUserApproval => "in-browser auth".to_string(), } } } impl From for AuthCredential { fn from(cred: UserCertificateCredential) -> Self { AuthCredential::Certificate { certificate_pem: cred.certificate_pem, } } } impl From for Option { fn from(cred: AuthCredential) -> Self { match cred { AuthCredential::Certificate { certificate_pem } => { Some(UserCertificateCredential { certificate_pem }) } _ => None, } } } ================================================ FILE: warpgate-common/src/auth/mod.rs ================================================ mod cred; mod policy; mod selector; mod state; pub use cred::*; pub use policy::*; pub use selector::*; pub use state::*; ================================================ FILE: warpgate-common/src/auth/policy.rs ================================================ use std::collections::{HashMap, HashSet}; use super::{AuthCredential, CredentialKind}; pub enum CredentialPolicyResponse { Ok, Need(HashSet), } pub trait CredentialPolicy { fn is_sufficient( &self, protocol: &str, valid_credentials: &[AuthCredential], ) -> CredentialPolicyResponse; } pub struct AnySingleCredentialPolicy { pub supported_credential_types: HashSet, } pub struct AllCredentialsPolicy { pub required_credential_types: HashSet, pub supported_credential_types: HashSet, } pub struct PerProtocolCredentialPolicy { pub protocols: HashMap<&'static str, Box>, pub default: Box, } impl CredentialPolicy for AnySingleCredentialPolicy { fn is_sufficient( &self, _protocol: &str, valid_credentials: &[AuthCredential], ) -> CredentialPolicyResponse { if valid_credentials.is_empty() { CredentialPolicyResponse::Need( self.supported_credential_types .clone() .into_iter() .collect(), ) } else { CredentialPolicyResponse::Ok } } } impl CredentialPolicy for AllCredentialsPolicy { fn is_sufficient( &self, _protocol: &str, valid_credentials: &[AuthCredential], ) -> CredentialPolicyResponse { let valid_credential_types: HashSet = valid_credentials.iter().map(|x| x.kind()).collect(); if !valid_credential_types.is_empty() && valid_credential_types.is_superset(&self.required_credential_types) { CredentialPolicyResponse::Ok } else { CredentialPolicyResponse::Need( self.required_credential_types .difference(&valid_credential_types) .cloned() .collect(), ) } } } impl CredentialPolicy for PerProtocolCredentialPolicy { fn is_sufficient( &self, protocol: &str, valid_credentials: &[AuthCredential], ) -> CredentialPolicyResponse { if let Some(policy) = self.protocols.get(protocol) { policy.is_sufficient(protocol, valid_credentials) } else { self.default.is_sufficient(protocol, valid_credentials) } } } ================================================ FILE: warpgate-common/src/auth/selector.rs ================================================ use std::fmt::Debug; use crate::consts::TICKET_SELECTOR_PREFIX; use crate::Secret; pub enum AuthSelector { User { username: String, target_name: String, }, Ticket { secret: Secret, }, } impl> From for AuthSelector { fn from(selector: T) -> Self { if let Some(secret) = selector.as_ref().strip_prefix(TICKET_SELECTOR_PREFIX) { let secret = Secret::new(secret.into()); return AuthSelector::Ticket { secret }; } let separator = if selector.as_ref().contains('#') { '#' } else { ':' }; let mut parts = selector.as_ref().splitn(2, separator); 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, "<{username} for {target_name}>"), AuthSelector::Ticket { .. } => write!(f, ""), } } } ================================================ FILE: warpgate-common/src/auth/state.rs ================================================ use std::collections::HashSet; use chrono::{DateTime, Utc}; use rand::Rng; use tokio::sync::broadcast; use tracing::{debug, info}; use uuid::Uuid; use super::{AuthCredential, CredentialKind, CredentialPolicy, CredentialPolicyResponse}; use crate::{SessionId, User, WarpgateConfig, WarpgateError}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum AuthResult { Accepted { user_info: AuthStateUserInfo }, Need(HashSet), Rejected, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct AuthStateUserInfo { pub id: Uuid, pub username: String, } impl From<&User> for AuthStateUserInfo { fn from(user: &User) -> Self { AuthStateUserInfo { id: user.id, username: user.username.clone(), } } } pub struct AuthState { id: Uuid, user_info: AuthStateUserInfo, session_id: Option, protocol: String, force_rejected: bool, policy: Box, valid_credentials: Vec, started: DateTime, identification_string: String, last_result: Option, state_change_signal: broadcast::Sender, } fn generate_identification_string() -> String { let mut s = String::new(); let mut rng = rand::thread_rng(); for _ in 0..4 { s.push_str(&format!("{:X}", rng.gen_range(0..16))); } s } impl AuthState { pub fn new( id: Uuid, session_id: Option, user_info: AuthStateUserInfo, protocol: String, policy: Box, state_change_signal: broadcast::Sender, ) -> Self { let mut this = Self { id, session_id, user_info, protocol, force_rejected: false, policy, valid_credentials: vec![], started: Utc::now(), identification_string: generate_identification_string(), last_result: None, state_change_signal, }; this.maybe_update_verification_state(); this } pub fn id(&self) -> &Uuid { &self.id } pub fn session_id(&self) -> &Option { &self.session_id } pub fn user_info(&self) -> &AuthStateUserInfo { &self.user_info } pub fn protocol(&self) -> &str { &self.protocol } pub fn started(&self) -> &DateTime { &self.started } pub fn identification_string(&self) -> &str { &self.identification_string } pub fn add_valid_credential(&mut self, credential: AuthCredential) { self.valid_credentials.push(credential); self.maybe_update_verification_state(); } pub fn reject(&mut self) { self.force_rejected = true; } pub fn verify(&self) -> AuthResult { self.current_verification_state() } fn current_verification_state(&self) -> AuthResult { if self.force_rejected { return AuthResult::Rejected; } match self .policy .is_sufficient(&self.protocol, &self.valid_credentials[..]) { CredentialPolicyResponse::Ok => { info!( username=%self.user_info.username, credentials=%self.valid_credentials .iter() .map(|x| x.safe_description()) .collect::>() .join(", "), "Authenticated", ); AuthResult::Accepted { user_info: self.user_info.clone(), } } CredentialPolicyResponse::Need(kinds) => AuthResult::Need(kinds), } } fn maybe_update_verification_state(&mut self) -> AuthResult { let new_result = self.current_verification_state(); if Some(new_result.clone()) != self.last_result { debug!( "Verification state changed for auth state {}: {:?} -> {:?}", self.id, self.last_result, &new_result ); let _ = self.state_change_signal.send(new_result.clone()); } self.last_result = Some(new_result.clone()); new_result } pub fn construct_web_approval_url( &self, config: &WarpgateConfig, ) -> Result { let mut external_url = config.construct_external_url(None, None)?; external_url.set_path("@warpgate"); external_url.set_fragment(Some(&format!("/login/{}", self.id()))); Ok(external_url) } } ================================================ FILE: warpgate-common/src/config/defaults.rs ================================================ use std::net::{Ipv6Addr, SocketAddr}; use std::time::Duration; use crate::{ListenEndpoint, Secret}; pub(crate) const fn _default_true() -> bool { true } pub(crate) const fn _default_false() -> bool { false } pub(crate) const fn _default_ssh_port() -> u16 { 22 } pub(crate) const fn _default_mysql_port() -> u16 { 3306 } #[inline] pub(crate) fn _default_username() -> String { "root".to_owned() } #[inline] pub(crate) fn _default_empty_string() -> String { "".to_owned() } #[inline] pub(crate) fn _default_recordings_path() -> String { "./data/recordings".to_owned() } #[inline] pub(crate) fn _default_database_url() -> Secret { Secret::new("sqlite:data/db".to_owned()) } #[inline] pub(crate) fn _default_http_listen() -> ListenEndpoint { ListenEndpoint::from(SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), 8888)) } #[inline] pub(crate) fn _default_mysql_listen() -> ListenEndpoint { ListenEndpoint::from(SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), 33306)) } #[inline] pub(crate) fn _default_postgres_listen() -> ListenEndpoint { ListenEndpoint::from(SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), 55432)) } #[inline] pub(crate) fn _default_kubernetes_listen() -> ListenEndpoint { ListenEndpoint::from(SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), 8443)) } #[inline] pub(crate) fn _default_retention() -> Duration { Duration::from_secs(60 * 60 * 24 * 7) } #[inline] pub(crate) fn _default_session_max_age() -> Duration { Duration::from_secs(60 * 30) } #[inline] pub(crate) fn _default_cookie_max_age() -> Duration { Duration::from_secs(60 * 60 * 24) } #[inline] pub(crate) fn _default_empty_vec() -> Vec { vec![] } pub(crate) fn _default_ssh_listen() -> ListenEndpoint { ListenEndpoint::from(SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), 2222)) } pub(crate) fn _default_ssh_keys_path() -> String { "./data/keys".to_owned() } pub(crate) fn _default_ssh_inactivity_timeout() -> Duration { Duration::from_secs(60 * 5) } pub(crate) fn _default_postgres_idle_timeout_str() -> Option { Some("10m".to_string()) } ================================================ FILE: warpgate-common/src/config/mod.rs ================================================ mod defaults; mod target; use std::ops::Deref; use std::path::PathBuf; use std::time::Duration; use defaults::*; use poem::http::uri; use poem_openapi::{Object, Union}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; pub use target::*; use tracing::warn; use uri::Scheme; use url::Url; use uuid::Uuid; use warpgate_sso::SsoProviderConfig; use warpgate_tls::IntoTlsCertificateRelativePaths; use crate::auth::CredentialKind; use crate::helpers::hash::hash_password; use crate::helpers::otp::OtpSecretKey; use crate::{ListenEndpoint, Secret, WarpgateError}; #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Union)] #[serde(tag = "type")] #[oai(discriminator_name = "kind", one_of)] pub enum UserAuthCredential { #[serde(rename = "password")] Password(UserPasswordCredential), #[serde(rename = "publickey")] PublicKey(UserPublicKeyCredential), #[serde(rename = "certificate")] Certificate(UserCertificateCredential), #[serde(rename = "otp")] Totp(UserTotpCredential), #[serde(rename = "sso")] Sso(UserSsoCredential), } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object)] pub struct UserPasswordCredential { pub hash: Secret, } impl UserPasswordCredential { pub fn from_password(password: &Secret) -> Self { Self { hash: Secret::new(hash_password(password.expose_secret())), } } } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object)] pub struct UserPublicKeyCredential { pub key: Secret, } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object)] pub struct UserCertificateCredential { pub certificate_pem: Secret, } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object)] pub struct UserTotpCredential { #[serde(with = "crate::helpers::serde_base64_secret")] pub key: OtpSecretKey, } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object)] pub struct UserSsoCredential { pub provider: Option, pub email: String, } impl UserAuthCredential { pub fn kind(&self) -> CredentialKind { match self { Self::Password(_) => CredentialKind::Password, Self::PublicKey(_) => CredentialKind::PublicKey, Self::Certificate(_) => CredentialKind::Certificate, Self::Totp(_) => CredentialKind::Totp, Self::Sso(_) => CredentialKind::Sso, } } } #[derive(Debug, Deserialize, Serialize, Clone, Object, Default)] pub struct UserRequireCredentialsPolicy { #[serde(skip_serializing_if = "Option::is_none")] pub http: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub kubernetes: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub ssh: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub mysql: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub postgres: Option>, } impl UserRequireCredentialsPolicy { #[must_use] pub fn upgrade_to_otp(&self, with_existing_credentials: &[UserAuthCredential]) -> Self { let mut copy = self.clone(); if let Some(policy) = &mut copy.http { policy.push(CredentialKind::Totp); } else { // Upgrade to OTP only if there is a password credential let mut kinds = vec![]; if with_existing_credentials .iter() .any(|c| c.kind() == CredentialKind::Password) { kinds.push(CredentialKind::Password); } if !kinds.is_empty() { kinds.push(CredentialKind::Totp); copy.http = Some(kinds); } } if let Some(policy) = &mut copy.ssh { policy.push(CredentialKind::Totp); } else { // Upgrade to OTP only if there is a password or public key credential let mut kinds = vec![]; if with_existing_credentials.iter().any(|c| { c.kind() == CredentialKind::Password || c.kind() == CredentialKind::PublicKey }) { kinds.push(CredentialKind::Password); } if !kinds.is_empty() { kinds.push(CredentialKind::Totp); copy.ssh = Some(kinds); } } copy } } #[derive(Debug, Deserialize, Serialize, Clone, Object)] pub struct User { #[serde(default)] pub id: Uuid, pub username: String, pub description: String, #[serde(skip_serializing_if = "Option::is_none", rename = "require")] pub credential_policy: Option, pub rate_limit_bytes_per_second: Option, #[serde(skip_serializing_if = "Option::is_none")] pub ldap_server_id: Option, } #[derive(Debug, Deserialize, Serialize, Clone, Object)] pub struct UserDetails { pub inner: User, pub credentials: Vec, pub roles: Vec, } impl Deref for UserDetails { type Target = User; fn deref(&self) -> &Self::Target { &self.inner } } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, Object)] pub struct Role { #[serde(default)] pub id: Uuid, pub name: String, pub description: String, } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object)] pub struct AdminRole { #[serde(default)] pub id: Uuid, pub name: String, pub description: String, pub targets_create: bool, pub targets_edit: bool, pub targets_delete: bool, pub users_create: bool, pub users_edit: bool, pub users_delete: bool, pub access_roles_create: bool, pub access_roles_edit: bool, pub access_roles_delete: bool, pub access_roles_assign: bool, pub sessions_view: bool, pub sessions_terminate: bool, pub recordings_view: bool, pub tickets_create: bool, pub tickets_delete: bool, pub config_edit: bool, pub admin_roles_manage: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum AdminPermission { TargetsCreate, TargetsEdit, TargetsDelete, UsersCreate, UsersEdit, UsersDelete, AccessRolesCreate, AccessRolesEdit, AccessRolesDelete, AccessRolesAssign, SessionsView, SessionsTerminate, RecordingsView, TicketsCreate, TicketsDelete, ConfigEdit, AdminRolesManage, } impl AdminRole { pub fn has_permission(&self, perm: AdminPermission) -> bool { match perm { AdminPermission::TargetsCreate => self.targets_create, AdminPermission::TargetsEdit => self.targets_edit, AdminPermission::TargetsDelete => self.targets_delete, AdminPermission::UsersCreate => self.users_create, AdminPermission::UsersEdit => self.users_edit, AdminPermission::UsersDelete => self.users_delete, AdminPermission::AccessRolesCreate => self.access_roles_create, AdminPermission::AccessRolesEdit => self.access_roles_edit, AdminPermission::AccessRolesDelete => self.access_roles_delete, AdminPermission::AccessRolesAssign => self.access_roles_assign, AdminPermission::SessionsView => self.sessions_view, AdminPermission::SessionsTerminate => self.sessions_terminate, AdminPermission::RecordingsView => self.recordings_view, AdminPermission::TicketsCreate => self.tickets_create, AdminPermission::TicketsDelete => self.tickets_delete, AdminPermission::ConfigEdit => self.config_edit, AdminPermission::AdminRolesManage => self.admin_roles_manage, } } } #[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq, Eq, Copy, JsonSchema)] pub enum SshHostKeyVerificationMode { #[serde(rename = "prompt")] #[default] Prompt, #[serde(rename = "auto_accept")] AutoAccept, #[serde(rename = "auto_reject")] AutoReject, } #[derive( Debug, Deserialize, Serialize, Clone, Copy, Default, PartialEq, Eq, JsonSchema, clap::ValueEnum, )] #[serde(rename_all = "lowercase")] pub enum LogFormat { #[default] Text, Json, } #[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)] pub struct SshConfig { #[serde(default = "_default_false")] pub enable: bool, #[serde(default = "_default_ssh_listen")] pub listen: ListenEndpoint, #[serde(default)] pub external_port: Option, #[serde(default = "_default_ssh_keys_path")] pub keys: String, #[serde(default)] pub host_key_verification: SshHostKeyVerificationMode, #[serde(default = "_default_ssh_inactivity_timeout", with = "humantime_serde")] #[schemars(with = "String")] pub inactivity_timeout: Duration, #[serde(default)] pub keepalive_interval: Option, } impl Default for SshConfig { fn default() -> Self { SshConfig { enable: false, listen: _default_ssh_listen(), keys: _default_ssh_keys_path(), host_key_verification: Default::default(), external_port: None, inactivity_timeout: _default_ssh_inactivity_timeout(), keepalive_interval: None, } } } impl SshConfig { pub fn external_port(&self) -> u16 { self.external_port.unwrap_or(self.listen.port()) } } #[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)] pub struct SniCertificateConfig { pub certificate: String, pub key: String, } #[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)] pub struct HttpConfig { #[serde(default = "_default_http_listen")] pub listen: ListenEndpoint, #[serde(default)] pub external_port: Option, #[serde(default)] pub certificate: String, #[serde(default)] pub key: String, #[serde(default)] pub trust_x_forwarded_headers: bool, #[serde(default = "_default_session_max_age", with = "humantime_serde")] #[schemars(with = "String")] pub session_max_age: Duration, #[serde(default = "_default_cookie_max_age", with = "humantime_serde")] #[schemars(with = "String")] pub cookie_max_age: Duration, #[serde(default)] pub sni_certificates: Vec, } impl Default for HttpConfig { fn default() -> Self { HttpConfig { listen: _default_http_listen(), external_port: None, certificate: "".to_owned(), key: "".to_owned(), trust_x_forwarded_headers: false, session_max_age: _default_session_max_age(), cookie_max_age: _default_cookie_max_age(), sni_certificates: vec![], } } } impl HttpConfig { pub fn external_port(&self) -> u16 { self.external_port.unwrap_or(self.listen.port()) } } impl IntoTlsCertificateRelativePaths for HttpConfig { fn certificate_path(&self) -> PathBuf { self.certificate.as_str().into() } fn key_path(&self) -> PathBuf { self.key.as_str().into() } } impl IntoTlsCertificateRelativePaths for SniCertificateConfig { fn certificate_path(&self) -> PathBuf { self.certificate.as_str().into() } fn key_path(&self) -> PathBuf { self.key.as_str().into() } } #[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)] pub struct MySqlConfig { #[serde(default = "_default_false")] pub enable: bool, #[serde(default = "_default_mysql_listen")] pub listen: ListenEndpoint, #[serde(default)] pub external_port: Option, #[serde(default)] pub certificate: String, #[serde(default)] pub key: String, } impl Default for MySqlConfig { fn default() -> Self { MySqlConfig { enable: false, listen: _default_mysql_listen(), external_port: None, certificate: "".to_owned(), key: "".to_owned(), } } } impl MySqlConfig { pub fn external_port(&self) -> u16 { self.external_port.unwrap_or(self.listen.port()) } } #[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)] pub struct KubernetesConfig { #[serde(default = "_default_false")] pub enable: bool, #[serde(default = "_default_kubernetes_listen")] pub listen: ListenEndpoint, #[serde(default)] pub external_port: Option, #[serde(default)] pub certificate: String, #[serde(default)] pub key: String, #[serde(default = "_default_session_max_age", with = "humantime_serde")] #[schemars(with = "String")] pub session_max_age: Duration, } impl Default for KubernetesConfig { fn default() -> Self { KubernetesConfig { enable: false, listen: _default_kubernetes_listen(), external_port: None, certificate: "".to_owned(), key: "".to_owned(), session_max_age: _default_session_max_age(), } } } impl KubernetesConfig { pub fn external_port(&self) -> u16 { self.external_port.unwrap_or(self.listen.port()) } } #[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)] pub struct PostgresConfig { #[serde(default = "_default_false")] pub enable: bool, #[serde(default = "_default_postgres_listen")] pub listen: ListenEndpoint, #[serde(default)] pub external_port: Option, #[serde(default)] pub certificate: String, #[serde(default)] pub key: String, } impl Default for PostgresConfig { fn default() -> Self { PostgresConfig { enable: false, listen: _default_postgres_listen(), external_port: None, certificate: "".to_owned(), key: "".to_owned(), } } } impl PostgresConfig { pub fn external_port(&self) -> u16 { self.external_port.unwrap_or(self.listen.port()) } } #[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)] 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, JsonSchema)] pub struct LogConfig { #[serde(default = "_default_retention", with = "humantime_serde")] #[schemars(with = "String")] pub retention: Duration, #[serde(default)] pub send_to: Option, #[serde(default)] pub format: LogFormat, } impl Default for LogConfig { fn default() -> Self { Self { retention: _default_retention(), send_to: None, format: LogFormat::default(), } } } #[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)] pub struct WarpgateConfigStore { #[serde(default)] pub sso_providers: Vec, #[serde(default)] pub recordings: RecordingsConfig, #[serde(default)] pub external_host: Option, #[serde(default = "_default_database_url")] #[schemars(with = "String")] pub database_url: Secret, #[serde(default)] pub ssh: SshConfig, #[serde(default)] pub http: HttpConfig, #[serde(default)] pub kubernetes: KubernetesConfig, #[serde(default)] pub mysql: MySqlConfig, #[serde(default)] pub postgres: PostgresConfig, #[serde(default)] pub log: LogConfig, } impl Default for WarpgateConfigStore { fn default() -> Self { Self { sso_providers: vec![], recordings: <_>::default(), external_host: None, database_url: _default_database_url(), ssh: <_>::default(), http: <_>::default(), kubernetes: <_>::default(), mysql: <_>::default(), postgres: <_>::default(), log: <_>::default(), } } } #[derive(Debug, Clone)] pub struct WarpgateConfig { pub store: WarpgateConfigStore, } impl WarpgateConfig { pub fn external_host_from_config(&self) -> Option<(Scheme, String, Option)> { if let Some(external_host) = self.store.external_host.as_ref() { #[allow(clippy::unwrap_used)] let external_host = external_host.split(":").next().unwrap(); Some(( Scheme::HTTPS, external_host.to_owned(), self.store .http .external_port .or(Some(self.store.http.listen.port())), )) } else { None } } /// Extract external host:port from request headers pub fn external_host_from_request( &self, request: &poem::Request, ) -> Option<(Scheme, String, Option)> { let (mut scheme, mut host, mut port) = (Scheme::HTTPS, None, None); let trust_forwarded_headers = self.store.http.trust_x_forwarded_headers; // Try the Host header first scheme = request.uri().scheme().cloned().unwrap_or(scheme); let original_url = request.original_uri(); if let Some(original_host) = original_url.host() { host = Some(original_host.to_string()); port = original_url.port().map(|x| x.as_u16()); } // But prefer X-Forwarded-* headers if enabled if trust_forwarded_headers { scheme = request .header("x-forwarded-proto") .and_then(|x| Scheme::try_from(x).ok()) .unwrap_or(scheme); if let Some(xfh) = request.header("x-forwarded-host") { // XFH can contain both host and port let parts = xfh.split(':').collect::>(); host = parts.first().map(|x| x.to_string()).or(host); port = parts.get(1).and_then(|x| x.parse::().ok()); } port = request .header("x-forwarded-port") .and_then(|x| x.parse::().ok()) .or(port); } host.map(|host| (scheme, host, port)) } pub fn construct_external_url( &self, for_request: Option<&poem::Request>, domain_whitelist: Option<&[String]>, ) -> Result { let Some((scheme, host, port)) = for_request .and_then(|r| self.external_host_from_request(r)) .or(self.external_host_from_config()) else { return Err(WarpgateError::ExternalHostUnknown); }; if let Some(list) = domain_whitelist { if !list.contains(&host) { return Err(WarpgateError::ExternalHostNotWhitelisted( host.clone(), list.iter().map(|x| x.to_string()).collect(), )); } } let mut url = format!("{scheme}://{host}"); if let Some(port) = port { // can't `match` `Scheme` if scheme == Scheme::HTTP && port != 80 || scheme == Scheme::HTTPS && port != 443 { url = format!("{url}:{port}"); } }; Url::parse(&url).map_err(WarpgateError::UrlParse) } pub fn validate(&self) { if let Some(ref ext) = self.store.external_host { if ext.contains(':') { warn!("Looks like your `external_host` config option contains a port - it will be ignored."); warn!("Set the external port via the `http.external_port`, `ssh.external_port` or `mysql.external_port` options."); } } } } ================================================ FILE: warpgate-common/src/config/target.rs ================================================ use std::collections::HashMap; use poem_openapi::{Object, Union}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use warpgate_tls::TlsMode; use super::defaults::*; use crate::Secret; #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object)] pub struct KubernetesTargetCertificateAuth { pub certificate: Secret, pub private_key: Secret, } impl Default for KubernetesTargetCertificateAuth { fn default() -> Self { Self { certificate: Secret::new(String::new()), private_key: Secret::new(String::new()), } } } #[derive(Debug, Deserialize, Serialize, Clone, Object)] pub struct TargetSSHOptions { pub host: String, #[serde(default = "_default_ssh_port")] pub port: u16, #[serde(default = "_default_username")] pub username: String, #[serde(default)] pub allow_insecure_algos: Option, #[serde(default)] pub auth: SSHTargetAuth, } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Union)] #[serde(untagged)] #[oai(discriminator_name = "kind", one_of)] pub enum SSHTargetAuth { #[serde(rename = "password")] Password(SshTargetPasswordAuth), #[serde(rename = "publickey")] PublicKey(SshTargetPublicKeyAuth), } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object)] pub struct SshTargetPasswordAuth { pub password: Secret, } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object, Default)] pub struct SshTargetPublicKeyAuth {} impl Default for SSHTargetAuth { fn default() -> Self { SSHTargetAuth::PublicKey(SshTargetPublicKeyAuth::default()) } } #[derive(Debug, Deserialize, Serialize, Clone, Object)] pub struct TargetHTTPOptions { #[serde(default = "_default_empty_string")] pub url: String, #[serde(default)] pub tls: Tls, #[serde(default)] pub headers: Option>, #[serde(default)] pub external_host: Option, } #[derive(Debug, Deserialize, Serialize, Clone, Object)] pub struct Tls { #[serde(default)] pub mode: TlsMode, #[serde(default = "_default_true")] pub verify: bool, } #[allow(clippy::derivable_impls)] impl Default for Tls { fn default() -> Self { Self { mode: TlsMode::default(), verify: false, } } } #[derive(Debug, Deserialize, Serialize, Clone, Object)] pub struct TargetMySqlOptions { #[serde(default = "_default_empty_string")] pub host: String, #[serde(default = "_default_mysql_port")] pub port: u16, #[serde(default = "_default_username")] pub username: String, #[serde(default)] pub password: Option, #[serde(default)] pub tls: Tls, #[serde(default)] pub default_database_name: Option, } #[derive(Debug, Deserialize, Serialize, Clone, Object)] pub struct TargetPostgresOptions { #[serde(default = "_default_empty_string")] pub host: String, #[serde(default = "_default_mysql_port")] pub port: u16, #[serde(default = "_default_username")] pub username: String, #[serde(default)] pub password: Option, #[serde(default)] pub tls: Tls, #[serde(default = "_default_postgres_idle_timeout_str")] pub idle_timeout: Option, #[serde(default)] pub default_database_name: Option, } #[derive(Debug, Deserialize, Serialize, Clone, Object)] pub struct TargetKubernetesOptions { #[serde(default = "_default_empty_string")] pub cluster_url: String, #[serde(default)] pub tls: Tls, #[serde(default)] pub auth: KubernetesTargetAuth, } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Union)] #[serde(untagged)] #[oai(discriminator_name = "kind", one_of)] pub enum KubernetesTargetAuth { #[serde(rename = "token")] Token(KubernetesTargetTokenAuth), #[serde(rename = "certificate")] Certificate(KubernetesTargetCertificateAuth), } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object)] pub struct KubernetesTargetTokenAuth { pub token: Secret, } impl Default for KubernetesTargetAuth { fn default() -> Self { KubernetesTargetAuth::Certificate(KubernetesTargetCertificateAuth::default()) } } #[derive(Debug, Deserialize, Serialize, Clone, Object)] pub struct Target { #[serde(default)] pub id: Uuid, pub name: String, pub description: String, #[serde(default = "_default_empty_vec")] pub allow_roles: Vec, #[serde(flatten)] pub options: TargetOptions, pub rate_limit_bytes_per_second: Option, pub group_id: Option, } #[derive(Debug, Deserialize, Serialize, Clone, Union)] #[oai(discriminator_name = "kind", one_of)] pub enum TargetOptions { #[serde(rename = "ssh")] Ssh(TargetSSHOptions), #[serde(rename = "http")] Http(TargetHTTPOptions), #[serde(rename = "kubernetes")] Kubernetes(TargetKubernetesOptions), #[serde(rename = "mysql")] MySql(TargetMySqlOptions), #[serde(rename = "postgres")] Postgres(TargetPostgresOptions), } ================================================ FILE: warpgate-common/src/config_schema.rs ================================================ use schemars::schema_for; #[allow(clippy::unwrap_used)] fn main() { let schema = schema_for!(warpgate_common::WarpgateConfigStore); println!("{}", serde_json::to_string_pretty(&schema).unwrap()); } ================================================ FILE: warpgate-common/src/consts.rs ================================================ pub const TICKET_SELECTOR_PREFIX: &str = "ticket-"; ================================================ FILE: warpgate-common/src/error.rs ================================================ use std::error::Error; use poem::error::ResponseError; use poem_openapi::ApiResponse; use uuid::Uuid; use warpgate_ca::CaError; use warpgate_sso::SsoError; use warpgate_tls::RustlsSetupError; use crate::AdminPermission; #[derive(thiserror::Error, Debug)] pub enum WarpgateError { #[error("database error: {0}")] DatabaseError(#[from] sea_orm::DbErr), #[error("ticket not found: {0}")] InvalidTicket(Uuid), #[error("invalid credential type")] InvalidCredentialType, #[error(transparent)] Other(Box), #[error("user {0} not found")] UserNotFound(String), #[error("role {0} not found")] RoleNotFound(String), #[error("failed to parse URL: {0}")] UrlParse(#[from] url::ParseError), #[error("deserialization failed: {0}")] DeserializeJson(#[from] serde_json::Error), #[error("no valid Host header found and `external_host` config option is not set")] ExternalHostUnknown, #[error("current hostname ({0}) is not on the whitelist ({1:?})")] ExternalHostNotWhitelisted(String, Vec), #[error("URL contains no host")] NoHostInUrl, #[error("Inconsistent state error")] InconsistentState, #[error(transparent)] Anyhow(#[from] anyhow::Error), #[error(transparent)] Sso(#[from] SsoError), #[error(transparent)] Ca(#[from] CaError), #[error(transparent)] Ldap(#[from] warpgate_ldap::LdapError), #[error(transparent)] RusshKeys(#[from] russh::keys::Error), #[error("I/O: {0}")] Io(#[from] std::io::Error), #[error(transparent)] RateLimiterInsufficientCapacity(#[from] governor::InsufficientCapacity), #[error("Invalid rate limiter quota: {0}")] RateLimiterInvalidQuota(u32), #[error("Session end")] SessionEnd, #[error("rcgen: {0}")] RcGen(#[from] rcgen::Error), #[error("rustls setup: {0}")] TlsSetup(#[from] RustlsSetupError), #[error("reqwest: {0}")] Reqwest(#[from] reqwest::Error), #[error("admin role required")] NoAdminAccess, #[error("admin permission required: {0:?}")] NoAdminPermission(AdminPermission), } impl ResponseError for WarpgateError { fn status(&self) -> poem::http::StatusCode { match self { WarpgateError::InvalidTicket(_) | WarpgateError::UserNotFound(_) | WarpgateError::RoleNotFound(_) => poem::http::StatusCode::UNAUTHORIZED, WarpgateError::NoAdminAccess | WarpgateError::NoAdminPermission(_) => { poem::http::StatusCode::FORBIDDEN } _ => poem::http::StatusCode::INTERNAL_SERVER_ERROR, } } } impl WarpgateError { pub fn other(err: E) -> Self { Self::Other(Box::new(err)) } } impl ApiResponse for WarpgateError { fn meta() -> poem_openapi::registry::MetaResponses { poem::error::Error::meta() } fn register(registry: &mut poem_openapi::registry::Registry) { poem::error::Error::register(registry) } } ================================================ FILE: warpgate-common/src/eventhub.rs ================================================ use std::sync::Arc; use tokio::sync::mpsc::error::SendError; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; use crate::helpers::locks::{Mutex, MutexGuard}; pub struct EventSender { subscriptions: SubscriptionStore, } impl Clone for EventSender { fn clone(&self) -> Self { EventSender { subscriptions: self.subscriptions.clone(), } } } impl EventSender { async fn cleanup_subscriptions(&self) -> MutexGuard<'_, SubscriptionStoreInner> { let mut subscriptions = self.subscriptions.lock().await; subscriptions.retain(|(_, ref s)| !s.is_closed()); subscriptions } } impl<'h, E: Clone + 'h> EventSender { pub async fn send_all(&'h self, event: E) -> Result<(), SendError> { 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 { pub async fn send_once(&'h self, event: E) -> Result<(), SendError> { 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(UnboundedReceiver); impl EventSubscription { pub async fn recv(&mut self) -> Option { self.0.recv().await } } type SubscriptionStoreInner = Vec<(Box bool + Send>, UnboundedSender)>; type SubscriptionStore = Arc>>; pub struct EventHub { subscriptions: SubscriptionStore, } impl EventHub { pub fn setup() -> (Self, EventSender) { let subscriptions = Arc::new(Mutex::new(vec![])); ( Self { subscriptions: subscriptions.clone(), }, EventSender { subscriptions }, ) } pub async fn subscribe bool + Send + 'static>( &'_ self, filter: F, ) -> EventSubscription { let (sender, receiver) = unbounded_channel(); let mut subscriptions = self.subscriptions.lock().await; subscriptions.push((Box::new(filter), sender)); EventSubscription(receiver) } } ================================================ FILE: warpgate-common/src/helpers/fs.rs ================================================ use std::os::unix::prelude::PermissionsExt; use std::path::Path; use tracing::*; fn maybe_apply_permissions>( path: P, permissions: std::fs::Permissions, ) -> std::io::Result<()> { let current = std::fs::metadata(&path)?.permissions(); if (current.mode() & 0o777) != permissions.mode() { std::fs::set_permissions(path, permissions)?; } Ok(()) } fn warn_failure(e: &std::io::Error) { error!("Warning: failed to tighten file permissions: {}", e); error!("If you are managing file permissions externally and do not need Warpgate to change them, you can pass --skip-securing-files") } pub fn secure_directory>(path: P) -> std::io::Result<()> { maybe_apply_permissions(path.as_ref(), std::fs::Permissions::from_mode(0o700)) .inspect_err(warn_failure) } pub fn secure_file>(path: P) -> std::io::Result<()> { maybe_apply_permissions(path.as_ref(), std::fs::Permissions::from_mode(0o600)) .inspect_err(warn_failure) } ================================================ FILE: warpgate-common/src/helpers/hash.rs ================================================ use anyhow::Result; use argon2::password_hash::rand_core::OsRng; use argon2::password_hash::{Error, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; use argon2::Argon2; use data_encoding::HEXLOWER; use rand::Rng; use crate::Secret; pub fn hash_password(password: &str) -> String { let salt = SaltString::generate(&mut OsRng); let argon2 = Argon2::default(); // Only panics for invalid hash parameters #[allow(clippy::unwrap_used)] argon2 .hash_password(password.as_bytes(), &salt) .unwrap() .to_string() } pub fn parse_hash(hash: &str) -> Result, Error> { PasswordHash::new(hash) } pub fn verify_password_hash(password: &str, hash: &str) -> Result { let parsed_hash = parse_hash(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 { let mut bytes = [0; 32]; rand::thread_rng().fill(&mut bytes[..]); Secret::new(HEXLOWER.encode(&bytes)) } ================================================ FILE: warpgate-common/src/helpers/locks.rs ================================================ #[cfg(debug_assertions)] mod deadlock_detecting_mutex { use std::any::type_name; use std::collections::HashMap; use std::ops::{Deref, DerefMut}; use std::sync::atomic::{AtomicBool, Ordering}; use once_cell::sync::Lazy; use tokio::task::Id; static LOCK_IDENTITIES: Lazy, Vec>>> = Lazy::new(|| std::sync::Mutex::new(HashMap::new())); fn log_state() { eprintln!("Tokio task: {:?}", tokio::task::try_id()); #[allow(clippy::unwrap_used)] let ids = LOCK_IDENTITIES.lock().unwrap(); let identities = ids.get(&tokio::task::try_id()).cloned().unwrap_or_default(); if !identities.is_empty() { eprintln!("* Held locks: {:?}", identities); } } pub struct MutexGuard<'a, T> { inner: tokio::sync::MutexGuard<'a, T>, poisoned: &'a AtomicBool, } impl<'a, T> MutexGuard<'a, T> { pub fn new(inner: tokio::sync::MutexGuard<'a, T>, poisoned: &'a AtomicBool) -> Self { let this = Self { inner, poisoned }; #[allow(clippy::unwrap_used)] let mut ids = LOCK_IDENTITIES.lock().unwrap(); let identities = ids.entry(tokio::task::try_id()).or_default(); let id = this.identity(); identities.push(id); // eprintln!("Locking {} @ {:?}", this.identity(), tokio::task::try_id()); this } fn identity(&self) -> String { format!( "{:?}@{}", type_name::(), tokio::task::try_id().map_or("unknown".to_string(), |id| id.to_string()) ) } } impl Deref for MutexGuard<'_, T> { type Target = T; fn deref(&self) -> &Self::Target { &self.inner } } impl DerefMut for MutexGuard<'_, T> { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.inner } } impl Drop for MutexGuard<'_, T> { fn drop(&mut self) { let self_id = self.identity(); // eprintln!("Unlocking {} @ {:?}", self.identity(), tokio::task::try_id()); #[allow(clippy::panic)] if self.poisoned.load(Ordering::Relaxed) { eprintln!("[!!] MutexGuard dropped while poisoned"); log_state(); panic!(); } #[allow(clippy::unwrap_used)] let mut ids = LOCK_IDENTITIES.lock().unwrap(); if let Some(identities) = ids.get_mut(&tokio::task::try_id()) { identities.retain(|id| id != &self_id); } } } pub struct Mutex { inner: tokio::sync::Mutex, poisoned: AtomicBool, } impl Mutex { pub fn new(data: T) -> Self { Self { inner: tokio::sync::Mutex::new(data), poisoned: AtomicBool::new(false), } } pub async fn lock(&self) -> MutexGuard<'_, T> { self._lock().await } #[allow(clippy::panic)] async fn _lock(&self) -> MutexGuard<'_, T> { use std::time::Duration; tokio::select! { res = self.inner.lock() => MutexGuard::new(res, &self.poisoned), _ = tokio::time::sleep(Duration::from_secs(5)) => { self.poisoned.store(true, Ordering::Relaxed); eprintln!("[!!] Mutex lock took too long"); log_state(); panic!(); } } } } } #[cfg(debug_assertions)] pub use deadlock_detecting_mutex::{Mutex, MutexGuard}; #[cfg(not(debug_assertions))] pub use tokio::sync::{Mutex, MutexGuard}; ================================================ FILE: warpgate-common/src/helpers/mod.rs ================================================ pub mod fs; pub mod hash; pub mod locks; pub mod otp; pub mod rng; pub mod serde_base64; pub mod serde_base64_secret; pub mod websocket; ================================================ FILE: warpgate-common/src/helpers/otp.rs ================================================ use std::time::SystemTime; use rand::Rng; use totp_rs::{Algorithm, TOTP}; use super::rng::get_crypto_rng; use crate::types::Secret; pub type OtpExposedSecretKey = Vec; pub type OtpSecretKey = Secret; pub fn generate_key() -> OtpSecretKey { Secret::new(get_crypto_rng().gen::<[u8; 32]>().into()) } pub fn generate_setup_url(key: &OtpSecretKey, label: &str) -> Secret { let totp = get_totp(key, Some(label)); Secret::new(totp.get_url()) } fn get_totp(key: &OtpSecretKey, label: Option<&str>) -> TOTP { TOTP { algorithm: Algorithm::SHA1, digits: 6, skew: 1, step: 30, secret: key.expose_secret().clone(), issuer: Some("Warpgate".to_string()), account_name: label.unwrap_or("").to_string(), } } pub fn verify_totp(code: &str, key: &OtpSecretKey) -> bool { #[allow(clippy::unwrap_used)] let time = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap() .as_secs(); get_totp(key, None).check(code, time) } ================================================ FILE: warpgate-common/src/helpers/rng.rs ================================================ use rand::SeedableRng; use rand_chacha::ChaCha20Rng; pub fn get_crypto_rng() -> ChaCha20Rng { ChaCha20Rng::from_entropy() } ================================================ FILE: warpgate-common/src/helpers/serde_base64.rs ================================================ use data_encoding::BASE64; use serde::{Deserialize, Serializer}; pub fn serialize>( bytes: B, serializer: S, ) -> Result { serializer.serialize_str(&BASE64.encode(bytes.as_ref())) } pub fn deserialize<'de, D: serde::Deserializer<'de>, B: From>>( deserializer: D, ) -> Result { let s = String::deserialize(deserializer)?; Ok(BASE64 .decode(s.as_bytes()) .map_err(serde::de::Error::custom)? .into()) } ================================================ FILE: warpgate-common/src/helpers/serde_base64_secret.rs ================================================ use serde::Serializer; use super::serde_base64; use crate::Secret; pub fn serialize( secret: &Secret>, serializer: S, ) -> Result { serde_base64::serialize(secret.expose_secret(), serializer) } pub fn deserialize<'de, D: serde::Deserializer<'de>>( deserializer: D, ) -> Result>, D::Error> { let inner = serde_base64::deserialize(deserializer)?; Ok(Secret::new(inner)) } ================================================ FILE: warpgate-common/src/helpers/websocket.rs ================================================ use std::future::Future; use futures::{Sink, SinkExt, Stream, StreamExt}; use poem::web::websocket::Message; use tokio_tungstenite::tungstenite::{self, Utf8Bytes}; pub trait TungsteniteCompatibleWebsocketMessage { fn to_tungstenite_message(self) -> tungstenite::Message; fn from_tungstenite_message(m: tungstenite::Message) -> Self; } impl TungsteniteCompatibleWebsocketMessage for Message { fn to_tungstenite_message(self) -> tungstenite::Message { match self { Message::Binary(data) => tungstenite::Message::Binary(data.into()), Message::Text(text) => tungstenite::Message::Text(text.into()), Message::Ping(data) => tungstenite::Message::Ping(data.into()), Message::Pong(data) => tungstenite::Message::Pong(data.into()), Message::Close(data) => { tungstenite::Message::Close(data.map(|data| tungstenite::protocol::CloseFrame { code: u16::from(data.0).into(), reason: Utf8Bytes::from(data.1), })) } } } fn from_tungstenite_message(msg: tungstenite::Message) -> Self { match msg { tungstenite::Message::Binary(data) => Message::Binary(data.to_vec()), tungstenite::Message::Text(text) => Message::Text(text.to_string()), tungstenite::Message::Ping(data) => Message::Ping(data.to_vec()), tungstenite::Message::Pong(data) => Message::Pong(data.to_vec()), tungstenite::Message::Close(data) => Message::Close( data.map(|data| (u16::from(data.code).into(), data.reason.to_string())), ), tungstenite::Message::Frame(_) => unreachable!(), } } } impl TungsteniteCompatibleWebsocketMessage for reqwest_websocket::Message { fn to_tungstenite_message(self) -> tungstenite::Message { match self { reqwest_websocket::Message::Binary(data) => tungstenite::Message::Binary(data), reqwest_websocket::Message::Text(text) => { tungstenite::Message::Text(Utf8Bytes::from(text)) } reqwest_websocket::Message::Ping(data) => tungstenite::Message::Ping(data), reqwest_websocket::Message::Pong(data) => tungstenite::Message::Pong(data), reqwest_websocket::Message::Close { code, reason } => { tungstenite::Message::Close(Some(tungstenite::protocol::CloseFrame { code: u16::from(code).into(), reason: Utf8Bytes::from(reason), })) } } } fn from_tungstenite_message(msg: tungstenite::Message) -> Self { match msg { tungstenite::Message::Binary(data) => reqwest_websocket::Message::Binary(data), tungstenite::Message::Text(text) => reqwest_websocket::Message::Text(text.to_string()), tungstenite::Message::Ping(data) => reqwest_websocket::Message::Ping(data), tungstenite::Message::Pong(data) => reqwest_websocket::Message::Pong(data), tungstenite::Message::Close(data) => reqwest_websocket::Message::Close { code: data .as_ref() .map(|data| u16::from(data.code).into()) .unwrap_or(reqwest_websocket::CloseCode::Normal), reason: data.map(|data| data.reason.to_string()).unwrap_or_default(), }, tungstenite::Message::Frame(_) => unreachable!(), } } } impl TungsteniteCompatibleWebsocketMessage for tungstenite::Message { fn to_tungstenite_message(self) -> tungstenite::Message { self } fn from_tungstenite_message(msg: tungstenite::Message) -> Self { msg } } pub async fn pump_websocket< DM: TungsteniteCompatibleWebsocketMessage + Send, D: Sink + Send + Unpin, SM: TungsteniteCompatibleWebsocketMessage + Send, SE: Send, S: Stream> + Send + Unpin, FE: Send, F: FnMut(tungstenite::Message) -> Fut, Fut: Future> + Send, >( mut source: S, mut sink: D, mut callback: F, ) -> anyhow::Result<()> where anyhow::Error: From, anyhow::Error: From, anyhow::Error: From, { while let Some(msg) = source.next().await { let msg = msg?.to_tungstenite_message(); let msg = callback(msg).await?; sink.send(DM::from_tungstenite_message(msg)).await?; } Ok::<_, anyhow::Error>(()) } ================================================ FILE: warpgate-common/src/http_headers.rs ================================================ use std::collections::HashSet; use once_cell::sync::Lazy; use poem::http::{self, HeaderName}; /// Headers that should not be forwarded to upstream when proxying HTTP requests pub static DONT_FORWARD_HEADERS: Lazy> = Lazy::new(|| { #[allow(clippy::mutable_key_type)] let mut s = HashSet::new(); s.insert(http::header::ACCEPT_ENCODING); s.insert(http::header::SEC_WEBSOCKET_EXTENSIONS); s.insert(http::header::SEC_WEBSOCKET_ACCEPT); s.insert(http::header::SEC_WEBSOCKET_KEY); s.insert(http::header::SEC_WEBSOCKET_VERSION); s.insert(http::header::UPGRADE); s.insert(http::header::HOST); s.insert(http::header::CONNECTION); s.insert(http::header::STRICT_TRANSPORT_SECURITY); s.insert(http::header::UPGRADE_INSECURE_REQUESTS); s }); pub static X_FORWARDED_FOR: HeaderName = HeaderName::from_static("x-forwarded-for"); pub static X_FORWARDED_HOST: HeaderName = HeaderName::from_static("x-forwarded-host"); pub static X_FORWARDED_PROTO: HeaderName = HeaderName::from_static("x-forwarded-proto"); ================================================ FILE: warpgate-common/src/lib.rs ================================================ pub mod api; pub mod auth; mod config; pub mod consts; mod error; pub mod eventhub; pub mod helpers; pub mod http_headers; mod state; mod try_macro; mod types; pub mod version; pub use config::*; pub use error::WarpgateError; pub use state::GlobalParams; pub use types::*; ================================================ FILE: warpgate-common/src/state.rs ================================================ use std::path::PathBuf; use anyhow::Context; #[derive(Clone)] pub struct GlobalParams { config_path: PathBuf, should_secure_files: bool, paths_relative_to: PathBuf, } impl GlobalParams { pub fn new(config_path: PathBuf, should_secure_files: bool) -> anyhow::Result { Ok(Self { paths_relative_to: config_path .parent() .context("Failed to determine config parent directory")? .to_path_buf(), config_path, should_secure_files, }) } pub fn config_path(&self) -> &PathBuf { &self.config_path } pub fn paths_relative_to(&self) -> &PathBuf { &self.paths_relative_to } pub fn should_secure_files(&self) -> bool { self.should_secure_files } } ================================================ FILE: warpgate-common/src/try_macro.rs ================================================ #[macro_export] macro_rules! try_block { ($try:block catch ($err:ident : $errtype:ty) $catch:block) => {{ #[allow(unreachable_code)] let result: anyhow::Result<_, $errtype> = (|| Ok::<_, $errtype>($try))(); match result { Ok(_) => (), Err($err) => { { $catch }; } }; }}; (async $try:block catch ($err:ident : $errtype:ty) $catch:block) => {{ let result: anyhow::Result<_, $errtype> = (async { Ok::<_, $errtype>($try) }).await; match result { Ok(_) => (), Err($err) => { { $catch }; } }; }}; } #[test] #[allow(clippy::assertions_on_constants)] #[allow(clippy::unwrap_used)] fn test_catch() { let mut caught = false; try_block!({ let _: u32 = "asdf".parse()?; assert!(false) } catch (e: anyhow::Error) { assert_eq!(e.to_string(), "asdf".parse::().unwrap_err().to_string()); caught = true; }); assert!(caught); } #[test] #[allow(clippy::assertions_on_constants)] fn test_success() { try_block!({ let _: u32 = "123".parse()?; } catch (_e: anyhow::Error) { assert!(false) }); } ================================================ FILE: warpgate-common/src/types/aliases.rs ================================================ use uuid::Uuid; pub type SessionId = Uuid; pub type ProtocolName = &'static str; ================================================ FILE: warpgate-common/src/types/listen_endpoint.rs ================================================ use std::fmt::Debug; use std::io::ErrorKind; use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs}; use futures::stream::{iter, FuturesUnordered}; use futures::{Stream, StreamExt, TryStreamExt}; use poem::listener::Listener; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use tokio::net::{TcpListener, TcpStream}; use tokio_stream::wrappers::TcpListenerStream; use crate::WarpgateError; #[derive(Clone, JsonSchema)] pub struct ListenEndpoint(SocketAddr); impl ListenEndpoint { pub fn address(&self) -> SocketAddr { self.0 } pub fn addresses_to_listen_on(&self) -> Result, WarpgateError> { // For [::], explicitly return both addresses so that we are not affected // by the state of the ipv6only sysctl. if self.0.ip() == Ipv6Addr::UNSPECIFIED { let addr6 = SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), self.0.port()); let addr4 = SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), self.0.port()); let listener6 = std::net::TcpListener::bind(addr6)?; let listener4 = std::net::TcpListener::bind(addr4); let result = match listener4 { Ok(_) => vec![addr4, addr6], Err(e) if e.kind() == ErrorKind::AddrInUse => vec![addr6], Err(e) => return Err(WarpgateError::Io(e)), }; drop(listener6); Ok(result) } else { Ok(vec![self.0]) } } pub async fn tcp_listeners(&self) -> Result, WarpgateError> { Ok(self .addresses_to_listen_on()? .into_iter() .map(TcpListener::bind) .collect::>() .try_collect() .await?) } pub async fn poem_listener(&self) -> Result { let addrs = self.addresses_to_listen_on()?; #[allow(clippy::unwrap_used)] // length known >=1 let (first, rest) = addrs.split_first().unwrap(); let mut listener: poem::listener::BoxListener = poem::listener::TcpListener::bind(first.to_string()).boxed(); for addr in rest { listener = listener .combine(poem::listener::TcpListener::bind(addr.to_string())) .boxed(); } Ok(listener) } pub async fn tcp_accept_stream( &self, ) -> Result>, WarpgateError> { Ok(iter( self.tcp_listeners() .await? .into_iter() .map(TcpListenerStream::new), ) .flatten_unordered(None)) } pub fn port(&self) -> u16 { self.0.port() } } impl From for ListenEndpoint { fn from(addr: SocketAddr) -> Self { Self(addr) } } impl<'de> Deserialize<'de> for ListenEndpoint { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { let v: String = Deserialize::deserialize::(deserializer)?; let v = v .to_socket_addrs() .map_err(|e| { serde::de::Error::custom(format!( "failed to resolve {v} into a TCP endpoint: {e:?}" )) })? .next() .ok_or_else(|| { serde::de::Error::custom(format!("failed to resolve {v} into a TCP endpoint")) })?; Ok(Self(v)) } } impl Serialize for ListenEndpoint { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { self.0.serialize(serializer) } } impl Debug for ListenEndpoint { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.0.fmt(f) } } ================================================ FILE: warpgate-common/src/types/mod.rs ================================================ mod aliases; mod listen_endpoint; mod secret; pub use aliases::*; pub use listen_endpoint::*; pub use secret::*; ================================================ FILE: warpgate-common/src/types/secret.rs ================================================ use std::borrow::Cow; use std::fmt::Debug; use bytes::Bytes; use data_encoding::HEXLOWER; use delegate::delegate; use poem_openapi::registry::{MetaSchemaRef, Registry}; use poem_openapi::types::{ParseError, ParseFromJSON, ToJSON}; use rand::Rng; use serde::{Deserialize, Serialize}; use crate::helpers::rng::get_crypto_rng; #[derive(PartialEq, Eq, Clone)] pub struct Secret(T); impl Secret { pub fn random() -> Self { Secret::new(HEXLOWER.encode(&Bytes::from_iter(get_crypto_rng().gen::<[u8; 32]>()))) } } impl Secret { pub const fn new(v: T) -> Self { Self(v) } pub fn expose_secret(&self) -> &T { &self.0 } } impl From for Secret { fn from(v: T) -> Self { Self::new(v) } } impl<'de, T> Deserialize<'de> for Secret where T: Deserialize<'de>, { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { let v = Deserialize::deserialize::(deserializer)?; Ok(Self::new(v)) } } impl Serialize for Secret where T: Serialize, { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { self.0.serialize(serializer) } } impl Debug for Secret { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "") } } impl poem_openapi::types::Type for Secret { const IS_REQUIRED: bool = T::IS_REQUIRED; type RawValueType = T::RawValueType; type RawElementValueType = T::RawElementValueType; fn name() -> Cow<'static, str> { T::name() } fn schema_ref() -> MetaSchemaRef { T::schema_ref() } fn register(registry: &mut Registry) { T::register(registry) } delegate! { to self.0 { fn as_raw_value(&self) -> Option<&Self::RawValueType>; fn raw_element_iter( &'_ self, ) -> Box + '_>; fn is_empty(&self) -> bool; fn is_none(&self) -> bool; } } } impl ParseFromJSON for Secret { fn parse_from_json(value: Option) -> poem_openapi::types::ParseResult { T::parse_from_json(value) .map(Self::new) .map_err(|e| ParseError::custom(e.into_message())) } } impl ToJSON for Secret { fn to_json(&self) -> Option { self.0.to_json() } } ================================================ FILE: warpgate-common/src/version.rs ================================================ use git_version::git_version; pub fn warpgate_version() -> &'static str { git_version!( args = ["--tags", "--always", "--dirty=-modified"], fallback = "unknown" ) } ================================================ FILE: warpgate-common-http/Cargo.toml ================================================ [package] edition = "2021" license = "Apache-2.0" name = "warpgate-common-http" version = "0.22.0" [dependencies] warpgate-common = { path = "../warpgate-common" } warpgate-core = { path = "../warpgate-core" } poem.workspace = true poem-openapi.workspace = true serde.workspace = true tokio.workspace = true tracing.workspace = true uuid.workspace = true ================================================ FILE: warpgate-common-http/src/auth.rs ================================================ use serde::{Deserialize, Serialize}; use uuid::Uuid; #[derive(Clone, Serialize, Deserialize)] pub struct AuthStateId(pub Uuid); /// Represents the source of authentication of a session #[derive(Clone, Serialize, Deserialize, Debug)] pub enum SessionAuthorization { User(String), Ticket { username: String, target_name: String, }, } impl SessionAuthorization { pub fn username(&self) -> &String { match self { Self::User(username) => username, Self::Ticket { username, .. } => username, } } } /// Represents the source of authentication in a request #[derive(Clone, Serialize, Deserialize, Debug)] pub enum RequestAuthorization { Session(SessionAuthorization), UserToken { username: String }, AdminToken, } #[derive(Clone)] pub struct UnauthenticatedRequestContext { pub services: warpgate_core::Services, } /// Provided to API handlers as Data<> impl UnauthenticatedRequestContext { pub fn to_authenticated(&self, auth: RequestAuthorization) -> AuthenticatedRequestContext { AuthenticatedRequestContext { auth, services: self.services.clone(), } } } #[derive(Clone)] /// Provided to API handlers as Data<> when a request is authenticated pub struct AuthenticatedRequestContext { pub auth: RequestAuthorization, pub services: warpgate_core::Services, } impl RequestAuthorization { /// Returns a username if one is present (admin token has none) pub fn username(&self) -> Option<&String> { match self { Self::Session(auth) => Some(auth.username()), Self::UserToken { username } => Some(username), Self::AdminToken => None, } } } /// Check if a host is localhost or 127.x.x.x (for development/testing scenarios) pub fn is_localhost_host(host: &str) -> bool { host == "localhost" || host == "127.0.0.1" || host.starts_with("127.") } ================================================ FILE: warpgate-common-http/src/lib.rs ================================================ pub mod auth; pub mod logging; pub use auth::{AuthenticatedRequestContext, RequestAuthorization, SessionAuthorization}; use poem::http::HeaderName; pub static X_WARPGATE_TOKEN: HeaderName = HeaderName::from_static("x-warpgate-token"); ================================================ FILE: warpgate-common-http/src/logging.rs ================================================ use std::fmt::Debug; use std::net::ToSocketAddrs; use poem::http::{Method, StatusCode, Uri}; use poem::web::RemoteAddr; use poem::{Addr, Request}; use tracing::*; use warpgate_core::{Services, WarpgateServerHandle}; pub async fn get_client_ip(req: &Request, services: &Services) -> Option { let trust_x_forwarded_headers = { let config = services.config.lock().await; config.store.http.trust_x_forwarded_headers }; let socket_addr = match req.remote_addr() { // See [CertificateExtractorEndpoint] RemoteAddr(Addr::Custom("captured-cert", value)) => { #[allow(clippy::unwrap_used)] let original_remote_addr = value.split("|").next().unwrap(); original_remote_addr .to_socket_addrs() .ok() .and_then(|i| i.into_iter().next()) } other => other.as_socket_addr().cloned(), }; let remote_ip = socket_addr.map(|x| x.ip().to_string()); if trust_x_forwarded_headers { req.header("x-forwarded-for") .map(|x| x.to_string()) .or(remote_ip) } else { remote_ip } } pub async fn span_for_request( req: &Request, services: &Services, handle: Option<&WarpgateServerHandle>, ) -> poem::Result { let client_ip = get_client_ip(req, services) .await .unwrap_or("".into()); Ok(match handle { Some(handle) => { let ss = handle.session_state().lock().await; match ss.user_info.clone() { Some(ref user_info) => { info_span!("HTTP", session=%handle.id(), session_username=%user_info.username, %client_ip) } None => info_span!("HTTP", session=%handle.id(), %client_ip), } } None => info_span!("HTTP"), }) } pub fn log_request_result( method: &Method, url: &Uri, client_ip: Option<&str>, status: &StatusCode, ) { let client_ip = client_ip.unwrap_or(""); if status.is_server_error() || status.is_client_error() { warn!(%method, %url, %status, %client_ip, "Request failed"); } else { info!(%method, %url, %status, %client_ip, "Request"); } } pub fn log_request_error(method: &Method, url: &Uri, client_ip: Option<&str>, error: &E) { let client_ip = client_ip.unwrap_or(""); error!(%method, %url, ?error, %client_ip, "Request failed"); } ================================================ FILE: warpgate-core/Cargo.toml ================================================ [package] edition = "2021" license = "Apache-2.0" name = "warpgate-core" version = "0.22.0" [dependencies] warpgate-common = { version = "*", path = "../warpgate-common" } warpgate-db-entities = { version = "*", path = "../warpgate-db-entities" } warpgate-db-migrations = { version = "*", path = "../warpgate-db-migrations" } warpgate-ldap = { version = "*", path = "../warpgate-ldap" } anyhow.workspace = true argon2 = "0.5" async-trait = "0.1" bytes.workspace = true chrono = { version = "0.4", default-features = false, features = ["serde"] } data-encoding.workspace = true dialoguer.workspace = true enum_dispatch.workspace = true humantime-serde = "1.1" futures.workspace = true ldap3.workspace = true once_cell = "1.17" packet = "0.1" password-hash.workspace = true poem.workspace = true poem-openapi.workspace = true rand.workspace = true rand_chacha.workspace = true rand_core.workspace = true russh.workspace = true sea-orm.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true tokio.workspace = true totp-rs = { version = "5.0", features = ["otpauth"], default-features = false } tracing.workspace = true tracing-core = { version = "0.1", default-features = false } tracing-subscriber = { version = "0.3", default-features = false } url = { version = "2.2", default-features = false } uuid.workspace = true warpgate-sso = { version = "*", path = "../warpgate-sso", default-features = false } rustls.workspace = true webpki = { version = "0.22", default-features = false } governor.workspace = true [features] postgres = ["sea-orm/sqlx-postgres"] mysql = ["sea-orm/sqlx-mysql"] sqlite = ["sea-orm/sqlx-sqlite"] ================================================ FILE: warpgate-core/src/auth_state_store.rs ================================================ use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, Instant}; use once_cell::sync::Lazy; use tokio::sync::{broadcast, Mutex}; use uuid::Uuid; use warpgate_common::auth::{AuthResult, AuthState, CredentialKind}; use warpgate_common::{SessionId, WarpgateError}; use crate::{ConfigProvider, ConfigProviderEnum}; #[allow(clippy::unwrap_used)] pub static TIMEOUT: Lazy = Lazy::new(|| Duration::from_secs(60 * 10)); struct AuthCompletionSignal { sender: broadcast::Sender, created_at: Instant, } impl AuthCompletionSignal { pub fn is_expired(&self) -> bool { self.created_at.elapsed() > *TIMEOUT } } pub struct AuthStateStore { config_provider: Arc>, store: HashMap>, Instant)>, completion_signals: HashMap, web_auth_request_signal: broadcast::Sender, } impl AuthStateStore { pub fn new(config_provider: Arc>) -> Self { Self { store: HashMap::new(), config_provider, completion_signals: HashMap::new(), web_auth_request_signal: broadcast::channel(100).0, } } pub fn contains_key(&self, id: &Uuid) -> bool { self.store.contains_key(id) } pub async fn all_pending_web_auths_for_user( &self, username: &str, ) -> Vec>> { let mut results = vec![]; for auth in self.store.values() { { let inner = auth.0.lock().await; if inner.user_info().username != username { continue; } let AuthResult::Need(need) = inner.verify() else { continue; }; if !need.contains(&CredentialKind::WebUserApproval) { continue; } } results.push(auth.0.clone()) } results } pub fn get(&self, id: &Uuid) -> Option>> { self.store.get(id).map(|x| x.0.clone()) } pub fn subscribe_web_auth_request(&self) -> broadcast::Receiver { self.web_auth_request_signal.subscribe() } pub async fn create( &mut self, session_id: Option<&SessionId>, username: &str, protocol: &str, supported_credential_types: &[CredentialKind], ) -> Result<(Uuid, Arc>), WarpgateError> { let id = Uuid::new_v4(); let Some(user) = self .config_provider .lock() .await .list_users() .await? .iter() .find(|u| u.username == username) .cloned() else { return Err(WarpgateError::UserNotFound(username.into())); }; let policy = self .config_provider .lock() .await .get_credential_policy(username, supported_credential_types) .await?; let Some(policy) = policy else { return Err(WarpgateError::UserNotFound(username.into())); }; let (state_change_tx, mut state_change_rx) = broadcast::channel(1); let web_auth_request_signal = self.web_auth_request_signal.clone(); tokio::spawn(async move { while let Ok(AuthResult::Need(result)) = state_change_rx.recv().await { if result.contains(&CredentialKind::WebUserApproval) { let _ = web_auth_request_signal.send(id); } } }); let state = AuthState::new( id, session_id.copied(), (&user).into(), protocol.to_string(), policy, state_change_tx, ); self.store .insert(id, (Arc::new(Mutex::new(state)), Instant::now())); #[allow(clippy::unwrap_used)] Ok((id, self.get(&id).unwrap())) } pub fn subscribe(&mut self, id: Uuid) -> broadcast::Receiver { let signal = self.completion_signals.entry(id).or_insert_with(|| { let (sender, _) = broadcast::channel(1); AuthCompletionSignal { sender, created_at: Instant::now(), } }); signal.sender.subscribe() } pub async fn complete(&mut self, id: &Uuid) { let Some((state, _)) = self.store.get(id) else { return; }; if let Some(sig) = self.completion_signals.remove(id) { let _ = sig.sender.send(state.lock().await.verify()); } } pub async fn vacuum(&mut self) { self.store .retain(|_, (_, started_at)| started_at.elapsed() < *TIMEOUT); self.completion_signals .retain(|_, signal| !signal.is_expired()); } } ================================================ FILE: warpgate-core/src/config_providers/db.rs ================================================ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use chrono::Utc; use data_encoding::BASE64; use sea_orm::{ ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter, QueryOrder, Set, }; use tokio::sync::Mutex; use tracing::*; use uuid::Uuid; use warpgate_common::auth::{ AllCredentialsPolicy, AnySingleCredentialPolicy, AuthCredential, CredentialKind, CredentialPolicy, PerProtocolCredentialPolicy, }; use warpgate_common::helpers::hash::verify_password_hash; use warpgate_common::helpers::otp::verify_totp; use warpgate_common::{ Role, Target, User, UserAuthCredential, UserPasswordCredential, UserPublicKeyCredential, UserRequireCredentialsPolicy, UserSsoCredential, UserTotpCredential, WarpgateError, }; use warpgate_db_entities as entities; use warpgate_sso::SsoProviderConfig; use super::ConfigProvider; pub struct DatabaseConfigProvider { db: Arc>, } impl DatabaseConfigProvider { pub async fn new(db: &Arc>) -> Self { Self { db: db.clone() } } async fn sync_ldap_ssh_keys( &self, db: &DatabaseConnection, user_id: Uuid, ldap_server_id: Uuid, ldap_object_uuid: &Uuid, ) -> Result<(), WarpgateError> { // Fetch LDAP server config let ldap_server = entities::LdapServer::Entity::find_by_id(ldap_server_id) .one(db) .await? .ok_or_else(|| { warpgate_ldap::LdapError::InvalidConfiguration("LDAP server not found".to_string()) })?; if !ldap_server.enabled { debug!( "LDAP server {} is disabled, skipping SSH key sync", ldap_server.name ); return Ok(()); } let ldap_config = warpgate_ldap::LdapConfig::try_from(&ldap_server)?; // Find user in LDAP by object UUID let ldap_user = warpgate_ldap::find_user_by_uuid(&ldap_config, ldap_object_uuid).await?; let Some(ldap_user) = ldap_user else { warn!( "LDAP user with UUID {} not found in server {}", ldap_object_uuid, ldap_server.name ); return Ok(()); }; // Delete existing public key credentials for this user entities::PublicKeyCredential::Entity::delete_many() .filter(entities::PublicKeyCredential::Column::UserId.eq(user_id)) .exec(db) .await?; // Insert SSH keys from LDAP for ssh_key in &ldap_user.ssh_public_keys { let ssh_key = ssh_key.trim(); if ssh_key.is_empty() { continue; } // Parse and validate the SSH key let key_result = russh::keys::PublicKey::from_openssh(ssh_key); if let Ok(mut key) = key_result { key.set_comment(""); let openssh_key = key.to_openssh().map_err(russh::keys::Error::from)?; entities::PublicKeyCredential::ActiveModel { id: Set(Uuid::new_v4()), user_id: Set(user_id), date_added: Set(Some(Utc::now())), last_used: Set(None), label: Set("Public key synchronized from LDAP".to_string()), ..entities::PublicKeyCredential::ActiveModel::from(UserPublicKeyCredential { key: openssh_key.into(), }) } .insert(db) .await?; } else { warn!("Invalid SSH key from LDAP: {}", ssh_key); } } info!( "Synced {} SSH key(s) from LDAP for {}", ldap_user.ssh_public_keys.len(), ldap_user.username ); Ok(()) } async fn maybe_autocreate_sso_user( &self, db: &DatabaseConnection, credential: UserSsoCredential, preferred_username: String, default_credential_policy: Option, ) -> Result, WarpgateError> { // Check for LDAP servers with auto-linking enabled let ldap_servers: Vec = entities::LdapServer::Entity::find() .filter(entities::LdapServer::Column::Enabled.eq(true)) .filter(entities::LdapServer::Column::AutoLinkSsoUsers.eq(true)) .all(db) .await?; let mut ldap_server_id = None; let mut ldap_object_uuid = None; // Try to find user in LDAP servers for ldap_server in ldap_servers { let ldap_config = warpgate_ldap::LdapConfig::try_from(&ldap_server).map_err(|e| { warn!( "Failed to parse LDAP config for server {}: {}", ldap_server.name, e ); e })?; match warpgate_ldap::find_user_by_username(&ldap_config, &preferred_username).await { Ok(Some(ldap_user)) => { info!( "Found LDAP user for username {}: {:?}", preferred_username, ldap_user.username ); ldap_server_id = Some(ldap_server.id); ldap_object_uuid = Some(ldap_user.object_uuid); break; } Ok(None) => { debug!( "No LDAP user found with username {} in server {}", preferred_username, ldap_server.name ); } Err(e) => { warn!( "Error searching for LDAP user in {}: {}", ldap_server.name, e ); } } } let user = entities::User::ActiveModel { id: Set(Uuid::new_v4()), username: Set(preferred_username.clone()), description: Set("".into()), credential_policy: Set(default_credential_policy.unwrap_or_else(|| { serde_json::to_value(UserRequireCredentialsPolicy::default()).unwrap_or_default() })), rate_limit_bytes_per_second: Set(None), ldap_server_id: Set(ldap_server_id), ldap_object_uuid: Set(ldap_object_uuid), } .insert(db) .await?; entities::SsoCredential::ActiveModel { id: Set(Uuid::new_v4()), user_id: Set(user.id), ..credential.into() } .insert(db) .await?; if ldap_server_id.is_some() { info!( "Auto-created SSO user {} and linked to LDAP account", preferred_username ); } else { info!( "Auto-created SSO user {} (no LDAP link)", preferred_username ); } Ok(Some(preferred_username)) } } impl ConfigProvider for DatabaseConfigProvider { async fn list_users(&mut self) -> Result, WarpgateError> { let db = self.db.lock().await; let users = entities::User::Entity::find() .order_by_asc(entities::User::Column::Username) .all(&*db) .await?; let users: Result, _> = users.into_iter().map(|t| t.try_into()).collect(); users } async fn list_targets(&mut self) -> Result, WarpgateError> { let db = self.db.lock().await; let targets = entities::Target::Entity::find() .order_by_asc(entities::Target::Column::Name) .all(&*db) .await?; let targets: Result, _> = targets.into_iter().map(|t| t.try_into()).collect(); Ok(targets?) } async fn get_credential_policy( &mut self, username: &str, supported_credential_types: &[CredentialKind], ) -> Result>, WarpgateError> { let db = self.db.lock().await; let user_model = entities::User::Entity::find() .filter(entities::User::Column::Username.eq(username)) .one(&*db) .await?; let Some(user_model) = user_model else { error!("Selected user not found: {}", username); return Ok(None); }; let user = user_model.load_details(&db).await?; let mut available_credential_types = user .credentials .iter() .map(|x| x.kind()) .collect::>(); available_credential_types.insert(CredentialKind::WebUserApproval); let supported_credential_types = supported_credential_types .iter() .copied() .collect::>() .intersection(&available_credential_types) .copied() .collect::>(); // "Any single credential" policy should not include WebUserApproval // if other authentication methods are available because it could lead to user confusion let default_policy = Box::new(AnySingleCredentialPolicy { supported_credential_types: if supported_credential_types.len() > 1 { supported_credential_types .iter() .cloned() .filter(|x| x != &CredentialKind::WebUserApproval) .collect() } else { supported_credential_types.clone() }, }) as Box; if let Some(req) = user.credential_policy.clone() { let mut policy = PerProtocolCredentialPolicy { default: default_policy, protocols: HashMap::new(), }; if let Some(p) = req.http { policy.protocols.insert( "HTTP", Box::new(AllCredentialsPolicy { supported_credential_types: supported_credential_types.clone(), required_credential_types: p.into_iter().collect(), }), ); } if let Some(p) = req.mysql { policy.protocols.insert( "MySQL", Box::new(AllCredentialsPolicy { supported_credential_types: supported_credential_types.clone(), required_credential_types: p.into_iter().collect(), }), ); } if let Some(p) = req.postgres { policy.protocols.insert( "PostgreSQL", Box::new(AllCredentialsPolicy { supported_credential_types: supported_credential_types.clone(), required_credential_types: p.into_iter().collect(), }), ); } if let Some(p) = req.ssh { policy.protocols.insert( "SSH", Box::new(AllCredentialsPolicy { supported_credential_types, required_credential_types: p.into_iter().collect(), }), ); } Ok(Some( Box::new(policy) as Box )) } else { Ok(Some(default_policy)) } } async fn username_for_sso_credential( &mut self, client_credential: &AuthCredential, preferred_username: Option, sso_config: SsoProviderConfig, ) -> Result, WarpgateError> { let db = self.db.lock().await; let AuthCredential::Sso { provider: client_provider, email: client_email, } = client_credential else { return Ok(None); }; let cred = entities::SsoCredential::Entity::find() .filter( entities::SsoCredential::Column::Email.eq(client_email).and( entities::SsoCredential::Column::Provider .eq(client_provider) .or(entities::SsoCredential::Column::Provider.is_null()), ), ) .one(&*db) .await?; if let Some(cred) = cred { let user = cred.find_related(entities::User::Entity).one(&*db).await?; if let Some(user) = user { return Ok(Some(user.username.clone())); } } if sso_config.auto_create_users { let Some(preferred_username) = preferred_username else { error!("The OIDC server did not provide a preferred_username claim for this user"); return Ok(None); }; return self .maybe_autocreate_sso_user( &db, UserSsoCredential { email: client_email.clone(), provider: Some(client_provider.clone()), }, preferred_username, sso_config.default_credential_policy.clone(), ) .await; } Ok(None) } async fn validate_credential( &mut self, username: &str, client_credential: &AuthCredential, ) -> Result { let db = self.db.lock().await; let user_model = entities::User::Entity::find() .filter(entities::User::Column::Username.eq(username)) .one(&*db) .await?; let Some(user_model) = user_model else { error!("Selected user not found: {}", username); return Ok(false); }; // Sync SSH keys from LDAP if user is linked if matches!(client_credential, AuthCredential::PublicKey { .. }) { if let (Some(ldap_server_id), Some(ldap_object_uuid)) = (user_model.ldap_server_id, &user_model.ldap_object_uuid) { if let Err(e) = self .sync_ldap_ssh_keys(&db, user_model.id, ldap_server_id, ldap_object_uuid) .await { warn!( "Failed to sync SSH keys from LDAP for user {}: {}", username, e ); } } } let user_details = user_model.load_details(&db).await?; match client_credential { AuthCredential::PublicKey { kind, public_key_bytes, } => { let base64_bytes = BASE64.encode(public_key_bytes); let openssh_public_key = format!("{kind} {base64_bytes}"); debug!( username = &user_details.username[..], "Client key: {}", openssh_public_key ); Ok(user_details .credentials .iter() .any(|credential| match credential { UserAuthCredential::PublicKey(UserPublicKeyCredential { key: ref user_key, }) => &openssh_public_key == user_key.expose_secret(), _ => false, })) } AuthCredential::Password(client_password) => { Ok(user_details .credentials .iter() .any(|credential| match credential { UserAuthCredential::Password(UserPasswordCredential { hash: ref user_password_hash, }) => verify_password_hash( client_password.expose_secret(), user_password_hash.expose_secret(), ) .unwrap_or_else(|e| { error!( username = &user_details.username[..], "Error verifying password hash: {}", e ); false }), _ => false, })) } AuthCredential::Otp(client_otp) => { Ok(user_details .credentials .iter() .any(|credential| match credential { UserAuthCredential::Totp(UserTotpCredential { key: ref user_otp_key, }) => verify_totp(client_otp.expose_secret(), user_otp_key), _ => false, })) } AuthCredential::Sso { provider: client_provider, email: client_email, } => { for credential in user_details.credentials.iter() { if let UserAuthCredential::Sso(UserSsoCredential { ref provider, ref email, }) = credential { if provider.as_ref().unwrap_or(client_provider) == client_provider && email == client_email { return Ok(true); } } } Ok(false) } _ => Err(WarpgateError::InvalidCredentialType), } } async fn authorize_target( &mut self, username: &str, target_name: &str, ) -> Result { let db = self.db.lock().await; let target_model = entities::Target::Entity::find() .filter(entities::Target::Column::Name.eq(target_name)) .one(&*db) .await?; let user_model = entities::User::Entity::find() .filter(entities::User::Column::Username.eq(username)) .one(&*db) .await?; let Some(user_model) = user_model else { error!("Selected user not found: {}", username); return Ok(false); }; let Some(target_model) = target_model else { warn!("Selected target not found: {}", target_name); return Ok(false); }; let target_roles: HashSet = target_model .find_related(entities::Role::Entity) .all(&*db) .await? .into_iter() .map(Into::::into) .map(|x| x.name) .collect(); let user_roles: HashSet = user_model .find_related(entities::Role::Entity) .all(&*db) .await? .into_iter() .map(Into::::into) .map(|x| x.name) .collect(); let intersect = user_roles.intersection(&target_roles).count() > 0; Ok(intersect) } async fn apply_sso_role_mappings( &mut self, username: &str, managed_role_names: Option>, assigned_role_names: Vec, ) -> Result<(), WarpgateError> { let db = self.db.lock().await; let user = entities::User::Entity::find() .filter(entities::User::Column::Username.eq(username)) .one(&*db) .await? .ok_or_else(|| WarpgateError::UserNotFound(username.into()))?; let managed_role_names = match managed_role_names { Some(x) => x, None => entities::Role::Entity::find() .all(&*db) .await? .into_iter() .map(|x| x.name) .collect(), }; for role_name in managed_role_names.into_iter() { let Some(role) = entities::Role::Entity::find() .filter(entities::Role::Column::Name.eq(role_name.clone())) .one(&*db) .await? else { warn!("SSO role mapping references non-existent role {role_name:?}, skipping"); continue; }; let assignment = entities::UserRoleAssignment::Entity::find() .filter(entities::UserRoleAssignment::Column::UserId.eq(user.id)) .filter(entities::UserRoleAssignment::Column::RoleId.eq(role.id)) .one(&*db) .await?; match (assignment, assigned_role_names.contains(&role_name)) { (None, true) => { info!("Adding role {role_name} for user {username} (from SSO)"); let values = entities::UserRoleAssignment::ActiveModel { user_id: Set(user.id), role_id: Set(role.id), ..Default::default() }; values.insert(&*db).await?; } (Some(assignment), false) => { info!("Removing role {role_name} for user {username} (from SSO)"); assignment.delete(&*db).await?; } _ => (), } } Ok(()) } async fn apply_sso_admin_role_mappings( &mut self, username: &str, managed_admin_role_names: Option>, assigned_admin_role_names: Vec, ) -> Result<(), WarpgateError> { let db = self.db.lock().await; let user = entities::User::Entity::find() .filter(entities::User::Column::Username.eq(username)) .one(&*db) .await? .ok_or_else(|| WarpgateError::UserNotFound(username.into()))?; let managed_admin_role_names = match managed_admin_role_names { Some(x) => x, None => entities::AdminRole::Entity::find() .all(&*db) .await? .into_iter() .map(|x| x.name) .collect(), }; for role_name in managed_admin_role_names.into_iter() { let role = entities::AdminRole::Entity::find() .filter(entities::AdminRole::Column::Name.eq(role_name.clone())) .one(&*db) .await? .ok_or_else(|| WarpgateError::RoleNotFound(role_name.clone()))?; let assignment = entities::UserAdminRoleAssignment::Entity::find() .filter(entities::UserAdminRoleAssignment::Column::UserId.eq(user.id)) .filter(entities::UserAdminRoleAssignment::Column::AdminRoleId.eq(role.id)) .one(&*db) .await?; match (assignment, assigned_admin_role_names.contains(&role_name)) { (None, true) => { info!("Adding admin role {role_name} for user {username} (from SSO)"); let values = entities::UserAdminRoleAssignment::ActiveModel { user_id: Set(user.id), admin_role_id: Set(role.id), ..Default::default() }; values.insert(&*db).await?; } (Some(assignment), false) => { info!("Removing admin role {role_name} for user {username} (from SSO)"); assignment.delete(&*db).await?; } _ => (), } } Ok(()) } async fn update_public_key_last_used( &self, credential: Option, ) -> Result<(), WarpgateError> { let db = self.db.lock().await; let Some(AuthCredential::PublicKey { kind, public_key_bytes, }) = credential else { error!("Invalid or missing public key credential"); return Err(WarpgateError::InvalidCredentialType); }; // Encode public key and match it against the database let base64_bytes = data_encoding::BASE64.encode(&public_key_bytes); let openssh_public_key = format!("{kind} {base64_bytes}"); debug!( "Attempting to update last_used for public key: {}", openssh_public_key ); // Find the public key credential let public_key_credential = entities::PublicKeyCredential::Entity::find() .filter( entities::PublicKeyCredential::Column::OpensshPublicKey .eq(openssh_public_key.clone()), ) .one(&*db) .await?; let Some(public_key_credential) = public_key_credential else { warn!( "Public key not found in the database: {}", openssh_public_key ); return Ok(()); // Gracefully return if the key is not found }; // Update the `last_used` (last used) timestamp let mut active_model: entities::PublicKeyCredential::ActiveModel = public_key_credential.into(); active_model.last_used = Set(Some(Utc::now())); active_model.update(&*db).await.map_err(|e| { error!("Failed to update last_used for public key: {:?}", e); WarpgateError::DatabaseError(e) })?; Ok(()) } async fn validate_api_token(&mut self, token: &str) -> Result, WarpgateError> { let db = self.db.lock().await; let Some(ticket) = entities::ApiToken::Entity::find() .filter( entities::ApiToken::Column::Secret .eq(token) .and(entities::ApiToken::Column::Expiry.gt(Utc::now())), ) .one(&*db) .await? else { return Ok(None); }; let Some(user) = ticket .find_related(entities::User::Entity) .one(&*db) .await? else { return Err(WarpgateError::InconsistentState); }; Ok(Some(user.try_into()?)) } } ================================================ FILE: warpgate-core/src/config_providers/mod.rs ================================================ mod db; use std::sync::Arc; pub use db::DatabaseConfigProvider; use enum_dispatch::enum_dispatch; use sea_orm::ActiveValue::Set; use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter}; use tokio::sync::Mutex; use tracing::*; use uuid::Uuid; use warpgate_common::auth::{AuthCredential, AuthStateUserInfo, CredentialKind, CredentialPolicy}; use warpgate_common::{Secret, Target, User, WarpgateError}; use warpgate_db_entities as e; use warpgate_sso::SsoProviderConfig; #[enum_dispatch] pub enum ConfigProviderEnum { Database(DatabaseConfigProvider), } #[enum_dispatch(ConfigProviderEnum)] #[allow(async_fn_in_trait)] pub trait ConfigProvider { async fn list_users(&mut self) -> Result, WarpgateError>; async fn list_targets(&mut self) -> Result, WarpgateError>; async fn validate_credential( &mut self, username: &str, client_credential: &AuthCredential, ) -> Result; async fn username_for_sso_credential( &mut self, client_credential: &AuthCredential, preferred_username: Option, sso_config: SsoProviderConfig, ) -> Result, WarpgateError>; async fn apply_sso_role_mappings( &mut self, username: &str, managed_role_names: Option>, active_role_names: Vec, ) -> Result<(), WarpgateError>; /// Similar to `apply_sso_role_mappings` but operates on *admin* roles. async fn apply_sso_admin_role_mappings( &mut self, username: &str, managed_admin_role_names: Option>, active_admin_role_names: Vec, ) -> Result<(), WarpgateError>; async fn get_credential_policy( &mut self, username: &str, supported_credential_types: &[CredentialKind], ) -> Result>, WarpgateError>; async fn authorize_target( &mut self, username: &str, target: &str, ) -> Result; async fn update_public_key_last_used( &self, credential: Option, ) -> Result<(), WarpgateError>; async fn validate_api_token(&mut self, token: &str) -> Result, WarpgateError>; } //TODO: move this somewhere pub async fn authorize_ticket( db: &Arc>, secret: &Secret, ) -> Result, WarpgateError> { let db = db.lock().await; let ticket = { e::Ticket::Entity::find() .filter(e::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); } } // TODO maybe Ticket could properly reference the user model and then // AuthStateUserInfo could be constructed from it let Some(ticket_user) = e::User::Entity::find() .filter(e::User::Column::Username.eq(ticket.username.clone())) .one(&*db) .await? else { return Err(WarpgateError::UserNotFound(ticket.username.clone())); }; Ok(Some((ticket, (&User::try_from(ticket_user)?).into()))) } None => { warn!("Ticket not found: {}", &secret.expose_secret()); Ok(None) } } } pub async fn consume_ticket( db: &Arc>, ticket_id: &Uuid, ) -> Result<(), WarpgateError> { let db = db.lock().await; let ticket = e::Ticket::Entity::find_by_id(*ticket_id).one(&*db).await?; let Some(ticket) = ticket else { return Err(WarpgateError::InvalidTicket(*ticket_id)); }; if let Some(uses_left) = ticket.uses_left { let mut model: e::Ticket::ActiveModel = ticket.into(); model.uses_left = Set(Some(uses_left - 1)); model.update(&*db).await?; } Ok(()) } ================================================ FILE: warpgate-core/src/consts.rs ================================================ pub static BUILTIN_ADMIN_ROLE_NAME: &str = "warpgate:admin"; pub static BUILTIN_ADMIN_USERNAME: &str = "admin"; ================================================ FILE: warpgate-core/src/data.rs ================================================ use chrono::{DateTime, Utc}; use poem_openapi::Object; use serde::{Deserialize, Serialize}; use uuid::Uuid; use warpgate_common::{SessionId, Target}; use warpgate_db_entities::Session; #[derive(Serialize, Deserialize, Object)] pub struct SessionSnapshot { pub id: SessionId, pub username: Option, pub target: Option, pub started: DateTime, pub ended: Option>, pub ticket_id: Option, pub protocol: String, } impl From 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, protocol: model.protocol, } } } ================================================ FILE: warpgate-core/src/db/mod.rs ================================================ use std::time::Duration; use anyhow::Result; use sea_orm::sea_query::Expr; use sea_orm::{ ConnectOptions, Database, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter, TransactionTrait, }; use tracing::*; use warpgate_common::helpers::fs::secure_file; use warpgate_common::{GlobalParams, WarpgateConfig, WarpgateError}; use warpgate_db_entities::LogEntry; use warpgate_db_migrations::migrate_database; use crate::recordings::SessionRecordings; pub async fn connect_to_db( config: &WarpgateConfig, params: &GlobalParams, ) -> Result { let mut url = url::Url::parse(&config.store.database_url.expose_secret()[..])?; if url.scheme() == "sqlite" { let path = url.path(); let mut abs_path = params.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?; if params.should_secure_files() { 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?; migrate_database(&connection).await?; Ok(connection) } pub async fn populate_db( db: &mut DatabaseConnection, _config: &mut WarpgateConfig, ) -> Result<(), WarpgateError> { 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 .map_err(WarpgateError::from)?; 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 .map_err(WarpgateError::from)?; Ok(()) } pub async fn cleanup_db( db: &mut DatabaseConnection, recordings: &mut SessionRecordings, retention: &Duration, ) -> Result<()> { use warpgate_db_entities::{Recording, Session}; let cutoff = chrono::Utc::now() - chrono::Duration::from_std(*retention)?; LogEntry::Entity::delete_many() .filter(Expr::col(LogEntry::Column::Timestamp).lt(cutoff)) .exec(db) .await?; let recordings_to_delete = Recording::Entity::find() .filter(Expr::col(Session::Column::Ended).is_not_null()) .filter(Expr::col(Session::Column::Ended).lt(cutoff)) .all(db) .await?; for recording in recordings_to_delete { if let Err(error) = recordings .remove(&recording.session_id, &recording.name) .await { error!(session=%recording.session_id, name=%recording.name, %error, "Failed to remove recording"); } recording.delete(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(()) } ================================================ FILE: warpgate-core/src/lib.rs ================================================ mod auth_state_store; mod config_providers; pub mod consts; mod data; pub mod db; pub mod logging; mod protocols; pub mod rate_limiting; pub mod recordings; mod services; mod state; pub use auth_state_store::*; pub use config_providers::*; pub use data::*; pub use protocols::*; pub use services::*; pub use state::{SessionState, SessionStateInit, State}; ================================================ FILE: warpgate-core/src/logging/database.rs ================================================ use std::sync::Arc; use once_cell::sync::OnceCell; use sea_orm::query::JsonValue; use sea_orm::{ActiveModelTrait, DatabaseConnection}; use tokio::sync::Mutex; use tracing::*; use tracing_subscriber::registry::LookupSpan; use tracing_subscriber::Layer; use uuid::Uuid; use warpgate_db_entities::LogEntry; use super::layer::ValuesLogLayer; use super::values::SerializedRecordValues; static LOG_SENDER: OnceCell> = OnceCell::new(); pub fn make_database_logger_layer() -> impl Layer where S: Subscriber + for<'a> LookupSpan<'a>, { let _ = LOG_SENDER.set(tokio::sync::broadcast::channel(1024).0); ValuesLogLayer::new(|values| { if let Some(sender) = LOG_SENDER.get() { if let Some(entry) = values_to_log_entry_data(values) { let _ = sender.send(entry); } } }) } pub fn install_database_logger(database: Arc>) { tokio::spawn(async move { #[allow(clippy::expect_used)] let mut receiver = LOG_SENDER .get() .expect("Log sender not ready yet") .subscribe(); loop { match receiver.recv().await { Err(_) => break, Ok(log_entry) => { let database = database.lock().await; if let Err(error) = log_entry.insert(&*database).await { error!(?error, "Failed to store log entry"); } } } } }); } fn values_to_log_entry_data(mut values: SerializedRecordValues) -> Option { let session_id = (*values).remove("session"); let username = (*values).remove("session_username"); let message = (*values).remove("message").unwrap_or_default(); use sea_orm::ActiveValue::Set; let session_id = session_id.and_then(|x| Uuid::parse_str(&x).ok())?; Some(LogEntry::ActiveModel { id: Set(Uuid::new_v4()), text: Set(message), values: Set(values .into_values() .into_iter() .map(|(k, v)| (k, JsonValue::from(v))) .collect()), session_id: Set(session_id), username: Set(username), timestamp: Set(chrono::Utc::now()), }) } ================================================ FILE: warpgate-core/src/logging/json_console.rs ================================================ use std::io::{self, Write}; use chrono::Utc; use serde::Serialize; use serde_json::json; use tracing::{Event, Level, Subscriber}; use tracing_subscriber::layer::Context; use tracing_subscriber::registry::LookupSpan; use tracing_subscriber::Layer; use super::values::{RecordVisitor, SerializedRecordValues}; #[derive(Serialize)] struct JsonLogEntry { timestamp: String, level: &'static str, target: String, message: String, #[serde(flatten)] fields: SerializedRecordValues, } pub struct JsonConsoleLayer; impl Layer for JsonConsoleLayer where S: Subscriber + for<'a> LookupSpan<'a>, { fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) { // Only log warpgate events (same filter as ValuesLogLayer) if !event.metadata().target().starts_with("warpgate") { return; } // Collect span fields (same pattern as ValuesLogLayer) let mut values = SerializedRecordValues::new(); let current = ctx.current_span(); let parent_id = event.parent().or_else(|| current.id()); if let Some(parent_id) = parent_id { if let Some(span) = ctx.span(parent_id) { for span in span.scope().from_root() { if let Some(other_values) = span.extensions().get::() { values.extend((*other_values).clone().into_iter()); } } } } // Record event fields event.record(&mut RecordVisitor::new(&mut values)); // Extract message before moving values let message = values.remove("message").unwrap_or_default(); let entry = JsonLogEntry { timestamp: Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), level: level_to_str(event.metadata().level()), target: event.metadata().target().to_string(), message, fields: values, }; // Serialize with fallback on error (Requirement 3.3) let json = match serde_json::to_string(&entry) { Ok(j) => j, Err(_) => json!({ "timestamp": entry.timestamp, "level": entry.level, "target": entry.target, "message": entry.message, "_serialization_error": true }) .to_string(), }; let _ = writeln!(io::stdout(), "{}", json); } fn on_new_span( &self, attrs: &tracing_core::span::Attributes<'_>, id: &tracing_core::span::Id, ctx: Context<'_, S>, ) { // Store span values for later collection (same as ValuesLogLayer) let Some(span) = ctx.span(id) else { return }; if !span.metadata().target().starts_with("warpgate") { return; } let mut values = SerializedRecordValues::new(); attrs.record(&mut RecordVisitor::new(&mut values)); span.extensions_mut().replace(values); } } pub fn make_json_console_logger_layer() -> impl Layer where S: Subscriber + for<'a> LookupSpan<'a>, { JsonConsoleLayer } fn level_to_str(level: &Level) -> &'static str { match *level { Level::TRACE => "trace", Level::DEBUG => "debug", Level::INFO => "info", Level::WARN => "warn", Level::ERROR => "error", } } ================================================ FILE: warpgate-core/src/logging/layer.rs ================================================ use tracing::{Event, Level, Subscriber}; use tracing_subscriber::layer::Context; use tracing_subscriber::registry::LookupSpan; use super::values::{RecordVisitor, SerializedRecordValues}; pub struct ValuesLogLayer where C: Fn(SerializedRecordValues), { callback: C, } impl ValuesLogLayer where C: Fn(SerializedRecordValues), { pub fn new(callback: C) -> Self { Self { callback } } } impl tracing_subscriber::Layer for ValuesLogLayer where S: Subscriber + for<'a> LookupSpan<'a>, C: Fn(SerializedRecordValues), Self: 'static, { fn on_new_span( &self, attrs: &tracing_core::span::Attributes<'_>, id: &tracing_core::span::Id, ctx: Context<'_, S>, ) { let Some(span) = ctx.span(id) else { return }; if !span.metadata().target().starts_with("warpgate") { return; } let mut values = SerializedRecordValues::new(); attrs.record(&mut RecordVisitor::new(&mut values)); span.extensions_mut().replace(values); } fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) { if !event.metadata().target().starts_with("warpgate") { return; } if event.metadata().level() > &Level::INFO { return; } let mut values = SerializedRecordValues::new(); let current = ctx.current_span(); let parent_id = event.parent().or_else(|| current.id()); if let Some(parent_id) = parent_id { if let Some(span) = ctx.span(parent_id) { for span in span.scope().from_root() { if let Some(other_values) = span.extensions().get::() { values.extend((*other_values).clone().into_iter()); } } } } event.record(&mut RecordVisitor::new(&mut values)); (self.callback)(values); } } ================================================ FILE: warpgate-core/src/logging/mod.rs ================================================ mod database; mod json_console; mod layer; mod socket; mod values; pub use database::{install_database_logger, make_database_logger_layer}; pub use json_console::make_json_console_logger_layer; pub use socket::make_socket_logger_layer; ================================================ FILE: warpgate-core/src/logging/socket.rs ================================================ use bytes::BytesMut; use chrono::format::SecondsFormat; use chrono::Local; use tokio::net::UnixDatagram; use tracing::*; use tracing_subscriber::registry::LookupSpan; use tracing_subscriber::Layer; use warpgate_common::WarpgateConfig; use super::layer::ValuesLogLayer; static SKIP_KEY: &str = "is_socket_logging_error"; pub async fn make_socket_logger_layer(config: &WarpgateConfig) -> impl Layer where S: Subscriber + for<'a> LookupSpan<'a>, { let mut socket = None; let socket_address = config.store.log.send_to.clone(); if socket_address.is_some() { socket = UnixDatagram::unbound() .map_err(|error| { println!("Failed to create the log forwarding UDP socket: {error}"); }) .ok(); } let (tx, mut rx) = tokio::sync::mpsc::channel(1024); let got_socket = socket.is_some(); let layer = ValuesLogLayer::new(move |mut values| { if !got_socket || values.contains_key(&SKIP_KEY) { return; } values.insert( "timestamp", Local::now().to_rfc3339_opts(SecondsFormat::Nanos, false), ); let _ = tx.try_send(values); }); if !got_socket { return layer; } tokio::spawn(async move { while let Some(values) = rx.recv().await { let Some(ref socket) = socket else { return }; let Some(ref socket_address) = socket_address else { return; }; let Ok(serialized) = serde_json::to_vec(&values) else { eprintln!("Failed to serialize log entry {values:?}"); continue; }; let buffer = BytesMut::from(&serialized[..]); if let Err(error) = socket.send_to(buffer.as_ref(), socket_address).await { error!(%error, is_socket_logging_error=true, "Failed to forward log entry"); } } }); layer } ================================================ FILE: warpgate-core/src/logging/values.rs ================================================ use std::collections::HashMap; use std::fmt::Debug; use std::ops::DerefMut; use serde::Serialize; use tracing::field::Visit; use tracing_core::Field; pub type SerializedRecordValuesInner = HashMap<&'static str, String>; #[derive(Serialize, Debug)] pub struct SerializedRecordValues(SerializedRecordValuesInner); impl SerializedRecordValues { pub fn new() -> Self { Self(HashMap::new()) } pub fn into_values(self) -> SerializedRecordValuesInner { self.0 } } impl std::ops::Deref for SerializedRecordValues { type Target = SerializedRecordValuesInner; fn deref(&self) -> &Self::Target { &self.0 } } impl DerefMut for SerializedRecordValues { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 } } pub struct RecordVisitor<'a> { values: &'a mut SerializedRecordValues, } impl<'a> RecordVisitor<'a> { pub fn new(values: &'a mut SerializedRecordValues) -> Self { Self { values } } } impl Visit for RecordVisitor<'_> { fn record_str(&mut self, field: &Field, value: &str) { self.values.insert(field.name(), value.to_string()); } fn record_debug(&mut self, field: &Field, value: &dyn Debug) { self.values.insert(field.name(), format!("{value:?}")); } } ================================================ FILE: warpgate-core/src/protocols/handle.rs ================================================ use std::sync::Arc; use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter}; use tokio::io::{AsyncRead, AsyncWrite}; use tokio::sync::Mutex; use warpgate_common::auth::AuthStateUserInfo; use warpgate_common::{SessionId, Target, WarpgateError}; use warpgate_db_entities::Session; use crate::rate_limiting::{stack_rate_limiters, RateLimiterRegistry}; use crate::{SessionState, State}; pub trait SessionHandle { fn close(&mut self); } #[derive(Clone)] pub struct WarpgateServerHandle { id: SessionId, db: Arc>, state: Arc>, session_state: Arc>, rate_limiters_registry: Arc>, } impl WarpgateServerHandle { pub fn new( id: SessionId, db: Arc>, state: Arc>, session_state: Arc>, rate_limiters_registry: Arc>, ) -> Result { Ok(WarpgateServerHandle { id, db, state, session_state, rate_limiters_registry, }) } pub fn id(&self) -> SessionId { self.id } pub fn session_state(&self) -> &Arc> { &self.session_state } pub async fn set_user_info(&self, user_info: AuthStateUserInfo) -> Result<(), WarpgateError> { use sea_orm::ActiveValue::Set; { let mut state = self.session_state.lock().await; state.user_info = Some(user_info.clone()); state.emit_change() } let db = self.db.lock().await; Session::Entity::update_many() .set(Session::ActiveModel { username: Set(Some(user_info.username)), ..Default::default() }) .filter(Session::Column::Id.eq(self.id)) .exec(&*db) .await?; drop(db); self.update_rate_limiters().await?; Ok(()) } pub async fn set_target(&self, target: &Target) -> Result<(), WarpgateError> { use sea_orm::ActiveValue::Set; { let mut state = self.session_state.lock().await; state.target = Some(target.clone()); state.emit_change() } let db = self.db.lock().await; Session::Entity::update_many() .set(Session::ActiveModel { target_snapshot: Set(Some( serde_json::to_string(&target).map_err(WarpgateError::other)?, )), ..Default::default() }) .filter(Session::Column::Id.eq(self.id)) .exec(&*db) .await?; drop(db); self.update_rate_limiters().await?; Ok(()) } pub async fn wrap_stream( &mut self, stream: impl AsyncRead + AsyncWrite + Unpin + Send, ) -> Result { let (stream, mut handle) = stack_rate_limiters(stream); let mut ss = self.session_state.lock().await; self.rate_limiters_registry .lock() .await .update_rate_limiters(&ss, &mut handle) .await?; ss.rate_limiter_handles.push(handle); Ok(stream) } async fn update_rate_limiters(&self) -> Result<(), WarpgateError> { let mut state = self.session_state.lock().await; let mut registry = self.rate_limiters_registry.lock().await; registry.update_all_rate_limiters(&mut state).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; }); } } ================================================ FILE: warpgate-core/src/protocols/mod.rs ================================================ use std::fmt::Debug; use std::future::Future; use anyhow::Result; use warpgate_common::ListenEndpoint; mod handle; pub use handle::{SessionHandle, WarpgateServerHandle}; #[derive(Debug, thiserror::Error)] pub enum TargetTestError { #[error("unreachable")] Unreachable, #[error("authentication failed")] AuthenticationError, #[error("connection error: {0}")] ConnectionError(String), #[error("misconfigured: {0}")] Misconfigured(String), #[error("I/O: {0}")] Io(#[from] std::io::Error), #[error("dialoguer: {0}")] Dialoguer(#[from] dialoguer::Error), } pub trait ProtocolServer { fn name(&self) -> &'static str; fn run(self, address: ListenEndpoint) -> impl Future> + Send; } ================================================ FILE: warpgate-core/src/rate_limiting/limiter.rs ================================================ use std::fmt::Debug; use std::num::{NonZero, NonZeroU32}; use governor::clock::{Clock, QuantaClock, QuantaInstant}; use governor::Quota; use warpgate_common::WarpgateError; use super::shared_limiter::SharedWarpgateRateLimiter; use super::{InnerRateLimiter, RateLimiterDirection}; pub fn new_rate_limiter(bytes_per_second: NonZeroU32) -> InnerRateLimiter { let max_cells = NonZeroU32::MAX; let rate_limiter = InnerRateLimiter::keyed(Quota::per_second(bytes_per_second).allow_burst(max_cells)); // Keep the burst capacity high to allow checking in large buffers but // consume (burst - per_second) cells initially to ensure that // the rate limiter is in its "normal" state #[allow(clippy::unwrap_used)] // checked for key in [RateLimiterDirection::Read, RateLimiterDirection::Write] { let _ = rate_limiter.check_key_n( &key, (u32::from(max_cells) - u32::from(bytes_per_second)) .try_into() .unwrap(), ); } rate_limiter } pub fn assert_valid_quota(v: u32) -> Result { NonZeroU32::new(v).ok_or(WarpgateError::RateLimiterInvalidQuota(v)) } /// Houses a replaceable shared reference to a `governor` rate limiter /// /// Note: this struct cannot be publicly instantiated without being /// container in a `SharedWarpgateRateLimiter` because we want to prevent /// somebody putting it in a tokio::sync::Mutex. /// /// See [super] for details. pub struct WarpgateRateLimiter { inner: Option<(InnerRateLimiter, NonZeroU32)>, } impl Debug for WarpgateRateLimiter { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("WarpgateRateLimiter") .field("bytes_per_second", &self.inner.as_ref().map(|x| x.1)) .finish() } } impl WarpgateRateLimiter { pub fn now() -> QuantaInstant { QuantaClock::default().now() } pub fn unlimited() -> SharedWarpgateRateLimiter { Self { inner: None }.share() } pub fn limited(bytes_per_second: NonZeroU32) -> SharedWarpgateRateLimiter { let rate_limiter = new_rate_limiter(bytes_per_second); Self { inner: Some((rate_limiter, bytes_per_second)), } .share() } #[allow(clippy::new_ret_no_self)] pub fn new(bytes_per_second: Option) -> Result { match bytes_per_second { Some(bytes) => Ok(Self::limited(assert_valid_quota(bytes)?)), None => Ok(Self::unlimited()), } } pub fn replace(&mut self, bytes_per_second: Option) -> Result<(), WarpgateError> { match bytes_per_second { None => { self.inner = None; } Some(bytes) => { let bps = assert_valid_quota(bytes)?; self.inner = Some((new_rate_limiter(bps), bps)) } }; Ok(()) } #[must_use = "Must use the Instant to wait"] pub fn bytes_ready_at( &self, direction: RateLimiterDirection, bytes: usize, ) -> Result, WarpgateError> { let Some(ref inner) = self.inner else { return Ok(None); }; let bytes = match NonZero::new(bytes as u32) { Some(bytes) => bytes, None => return Ok(None), }; match inner.0.check_key_n(&direction, bytes)? { Ok(_) => Ok(None), Err(e) => Ok(Some(e.earliest_possible())), } } fn share(self) -> SharedWarpgateRateLimiter { SharedWarpgateRateLimiter::new(self) } } ================================================ FILE: warpgate-core/src/rate_limiting/mod.rs ================================================ //! ## Hierarchy //! //! * [WarpgateRateLimiter] - wraps a [governor::DefaultKeyedRateLimiter], allows mutating the quota and provides app specific logic. Each [WarpgateRateLimiter] houses its own unique rate limiter. //! * [SharedWarpgateRateLimiter] - makes [WarpgateRateLimiter] shareable and locked. //! * [SwappableLimiterCell] - a cell containing a [SharedWarpgateRateLimiter] reference which can be swapped out wholesale. Multiple [SwappableLimiterCell]s can reference the same rate limiter instance. //! //! ## Why are limiters locked with a [std::sync::Mutex]? //! //! The issue with that is that if a limiter in a [tokio] Mutex is then used //! within a `RateLimitedStream`, then the semantics of //! [tokio::io::split] (internal lock between read and write halves) //! will cause deadlock if read and write futures are interleaved and one of them has //! a pending wait on the async mutex. //! //! So instead we force it to always be wrapped in a [SharedWarpgateRateLimiter] //! with a sync mutex inside and never be used across awaits. //! ## Why different wrapper types? //! //! There are two types of live "replacements" going on with rate limiters: //! * Swapping out a limiter in a [RateLimitedStream]'s [SwappableLimiterCellHandle] //! when the related entity changes, e.g. when the user logs in and now //! a user limit applies to them. This is [SwappableLimiterCellHandle::replace] //! * Replacing the limit inside a concrete [WarpgateRateLimiter] when the limit //! is changed by the admin. This is [WarpgateRateLimiter::replace] mod limiter; mod registry; mod shared_limiter; mod stack; mod stream; mod swappable_cell; use governor::DefaultKeyedRateLimiter; pub use limiter::WarpgateRateLimiter; pub use registry::RateLimiterRegistry; pub use shared_limiter::{SharedWarpgateRateLimiter, SharedWarpgateRateLimiterGuard}; pub use stack::{stack_rate_limiters, RateLimiterStackHandle}; pub use stream::RateLimitedStream; pub use swappable_cell::{SwappableLimiterCell, SwappableLimiterCellHandle}; #[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)] pub enum RateLimiterDirection { Read, Write, } pub type InnerRateLimiter = DefaultKeyedRateLimiter; ================================================ FILE: warpgate-core/src/rate_limiting/registry.rs ================================================ use std::collections::HashMap; use std::sync::Arc; use sea_orm::{DatabaseConnection, EntityTrait}; use tokio::sync::Mutex; use tracing::debug; use uuid::Uuid; use warpgate_common::WarpgateError; use warpgate_db_entities::{Parameters, Target, User}; use super::shared_limiter::SharedWarpgateRateLimiter; use super::{RateLimiterStackHandle, WarpgateRateLimiter}; use crate::{SessionState, State}; pub struct RateLimiterRegistry { db: Arc>, global_rate_limiter: SharedWarpgateRateLimiter, user_rate_limiters: HashMap, target_rate_limiters: HashMap, } impl RateLimiterRegistry { pub fn new(db: Arc>) -> Self { Self { db, global_rate_limiter: WarpgateRateLimiter::unlimited(), user_rate_limiters: HashMap::new(), target_rate_limiters: HashMap::new(), } } // TODO granular refresh pub async fn refresh(&mut self) -> Result<(), WarpgateError> { let global_quota = self.global_quota().await?; self.global_rate_limiter.lock().replace(global_quota)?; for (user_id, limiter) in self.user_rate_limiters.iter() { let quota = self.quota_for_user(user_id).await?; limiter.lock().replace(quota)?; } for (target_id, limiter) in self.target_rate_limiters.iter() { let quota = self.quota_for_target(target_id).await?; limiter.lock().replace(quota)?; } Ok(()) } pub fn global(&self) -> SharedWarpgateRateLimiter { self.global_rate_limiter.clone() } async fn global_quota(&mut self) -> Result, WarpgateError> { let db = self.db.lock().await; let parameters = Parameters::Entity::get(&db).await?; Ok(parameters.rate_limit_bytes_per_second.map(|x| x as u32)) } pub async fn user( &mut self, user_id: &Uuid, ) -> Result { if !self.user_rate_limiters.contains_key(user_id) { let quota = self.quota_for_user(user_id).await?; let rate_limiter = WarpgateRateLimiter::new(quota)?; self.user_rate_limiters.insert(*user_id, rate_limiter); } #[allow(clippy::unwrap_used, reason = "just inserted")] Ok(self.user_rate_limiters.get(user_id).unwrap().clone()) } async fn quota_for_user(&self, user_id: &Uuid) -> Result, WarpgateError> { let db = self.db.lock().await; let user = User::Entity::find_by_id(*user_id).one(&*db).await?; Ok(user .and_then(|u| u.rate_limit_bytes_per_second) .map(|r| r as u32)) } pub async fn target( &mut self, target_id: &Uuid, ) -> Result { if !self.target_rate_limiters.contains_key(target_id) { let quota = self.quota_for_target(target_id).await?; let rate_limiter = WarpgateRateLimiter::new(quota)?; self.target_rate_limiters.insert(*target_id, rate_limiter); } #[allow(clippy::unwrap_used, reason = "just inserted")] Ok(self.target_rate_limiters.get(target_id).unwrap().clone()) } async fn quota_for_target(&self, target_id: &Uuid) -> Result, WarpgateError> { let db = self.db.lock().await; let target = Target::Entity::find_by_id(*target_id).one(&*db).await?; Ok(target .and_then(|t| t.rate_limit_bytes_per_second) .map(|r| r as u32)) } pub async fn update_all_rate_limiters( &mut self, state: &mut SessionState, ) -> Result<(), WarpgateError> { async fn inner( this: &mut RateLimiterRegistry, state: &mut SessionState, handles: &mut [RateLimiterStackHandle], ) -> Result<(), WarpgateError> { for handle in handles.iter_mut() { this.update_rate_limiters(state, handle).await?; } Ok(()) } let mut handles = std::mem::take(&mut state.rate_limiter_handles); // Defer result handling so that we can put back the handles let result = inner(self, state, &mut handles).await; state.rate_limiter_handles = handles; result } pub async fn update_rate_limiters( &mut self, state: &SessionState, handle: &mut RateLimiterStackHandle, ) -> Result<(), WarpgateError> { if let Some(user_info) = &state.user_info { let user_limiter = self.user(&user_info.id).await?; debug!("Setting user rate limit {user_limiter:?}"); handle.user.replace(Some(user_limiter)); } else { handle.user.replace(None); } if let Some(target) = &state.target { let target_limiter = self.target(&target.id).await?; debug!("Setting user rate limit {target_limiter:?}"); handle.target.replace(Some(target_limiter)); } else { handle.target.replace(None); } let global = self.global(); debug!("Setting global rate limit {global:?}"); handle.global.replace(Some(global)); Ok(()) } /// Force refresh all rate limiters in all sessions pub async fn apply_new_rate_limits(&mut self, state: &mut State) -> Result<(), WarpgateError> { // Refresh the global rate limiter self.refresh().await?; // Update all session rate limiters for session_state in state.sessions.values() { let mut session_state = session_state.lock().await; self.update_all_rate_limiters(&mut session_state).await?; } Ok(()) } } ================================================ FILE: warpgate-core/src/rate_limiting/shared_limiter.rs ================================================ use std::fmt::Debug; use std::ops::{Deref, DerefMut}; use std::sync::Arc; use super::WarpgateRateLimiter; #[derive(Clone, Debug)] pub struct SharedWarpgateRateLimiter { inner: Arc>, } impl SharedWarpgateRateLimiter { pub(crate) fn new(limiter: WarpgateRateLimiter) -> Self { Self { inner: Arc::new(std::sync::Mutex::new(limiter)), } } pub fn lock(&self) -> SharedWarpgateRateLimiterGuard<'_> { #[allow(clippy::unwrap_used, reason = "panic on poison")] SharedWarpgateRateLimiterGuard::new(self.inner.lock().unwrap()) } } /// Encapsulates a shared reference to a `WarpgateRateLimiter` in a mutex /// and prevents locks from being sent across awaits pub struct SharedWarpgateRateLimiterGuard<'a> { inner: std::sync::MutexGuard<'a, WarpgateRateLimiter>, // prevent locks across awaits _non_sendable: std::marker::PhantomData<*const ()>, } impl<'a> SharedWarpgateRateLimiterGuard<'a> { pub fn new(inner: std::sync::MutexGuard<'a, WarpgateRateLimiter>) -> Self { Self { inner, _non_sendable: std::marker::PhantomData, } } } impl Deref for SharedWarpgateRateLimiterGuard<'_> { type Target = WarpgateRateLimiter; fn deref(&self) -> &Self::Target { &self.inner } } impl DerefMut for SharedWarpgateRateLimiterGuard<'_> { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.inner } } ================================================ FILE: warpgate-core/src/rate_limiting/stack.rs ================================================ use tokio::io::{AsyncRead, AsyncWrite}; use super::swappable_cell::SwappableLimiterCellHandle; use super::RateLimitedStream; /// Three [RateLimitedStream]s in a trenchcoat, one with a global limiter, /// one with a user limiter and one with a target limiter, wrapping each other. /// The handle lets you swap out the limiters in each of them remotely. /// Created via [stack_rate_limiters]. pub struct RateLimiterStackHandle { pub user: SwappableLimiterCellHandle, pub target: SwappableLimiterCellHandle, pub global: SwappableLimiterCellHandle, } pub fn stack_rate_limiters( stream: S, ) -> ( impl AsyncRead + AsyncWrite + Unpin + Send, RateLimiterStackHandle, ) { let (stream, global_handle) = RateLimitedStream::new_unlimited(stream); let (stream, user_handle) = RateLimitedStream::new_unlimited(stream); let (stream, target_handle) = RateLimitedStream::new_unlimited(stream); ( stream, RateLimiterStackHandle { user: user_handle, target: target_handle, global: global_handle, }, ) } ================================================ FILE: warpgate-core/src/rate_limiting/stream.rs ================================================ use std::future::Future; use std::io; use std::pin::Pin; use std::task::{Context, Poll}; use std::time::{Duration, Instant}; use futures::FutureExt; use governor::clock::Reference; use governor::Jitter; use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; use crate::rate_limiting::{ RateLimiterDirection, SwappableLimiterCell, SwappableLimiterCellHandle, WarpgateRateLimiter, }; type WaitFuture = Pin + Send>>; enum PendingWaitState { /// No active wait -> may start new wait on poll. /// The usize is the number of bytes processed in the last operation. Empty(usize), /// Active wait is pending -> polls the wait Waiting(usize, WaitFuture), /// Active wait has ended -> polls as Ready Ready, } /// A Future-like container that can polled for I/O rate limiting. /// Remembers the state of the wait and can be reset. /// Call `poll_rate_limit` with IO operation params to wait. /// Once a single wait is done, will always poll Ready until `.reset()` is called. struct PendingWait { state: PendingWaitState, direction: RateLimiterDirection, } impl PendingWait { pub fn new(direction: RateLimiterDirection) -> Self { Self { state: PendingWaitState::Empty(0), direction, } } fn poll_rate_limit( &mut self, limiter: &mut SwappableLimiterCell, cx: &mut Context<'_>, ) -> Poll> { // The loop runs at most 2x loop { if let PendingWaitState::Empty(len) = self.state { // Check if we need to wait match limiter.bytes_ready_at(self.direction, len) { Ok(None) => { self.state = PendingWaitState::Ready; } Ok(Some(at)) => { let now_quanta = WarpgateRateLimiter::now(); let now_tokio = Instant::now(); let delta = Duration::from(at.duration_since(now_quanta)); let a = 10; // percent let tokio_deadline = Jitter::new(delta * (100 - a) / 100, delta * a * 2 / 100) + now_tokio; let fut = tokio::time::sleep_until(tokio_deadline.into()).boxed(); self.state = PendingWaitState::Waiting(len, fut); } Err(e) => { self.state = PendingWaitState::Empty(0); return Poll::Ready(Err(io::Error::other(e.to_string()))); } }; }; match self.state { PendingWaitState::Empty(_) => unreachable!(), PendingWaitState::Waiting(len, ref mut fut) => match fut.as_mut().poll(cx) { Poll::Pending => return Poll::Pending, Poll::Ready(_) => { // !! // Since we did not consume any cells // we need to try again and maybe wait // again if there aren't enough available yet. self.state = PendingWaitState::Empty(len); continue; } }, PendingWaitState::Ready => {} } break; } Poll::Ready(Ok(())) } pub fn reset(&mut self, last_chunk_size: usize) { self.state = PendingWaitState::Empty(last_chunk_size); } } // Deadlock alert: the same stream's `read_wait` internal future can become // paused while something else is waiting on `write_wait`, leading to a deadlock // since both are accessing the SwappableLimiterCell's internal lock. pub struct RateLimitedStream { inner: T, limiter: SwappableLimiterCell, read_wait: PendingWait, write_wait: PendingWait, } impl RateLimitedStream { pub fn new(inner: T, limiter: SwappableLimiterCell) -> Self { Self { inner, limiter, read_wait: PendingWait::new(RateLimiterDirection::Read), write_wait: PendingWait::new(RateLimiterDirection::Write), } } pub fn new_unlimited(inner: T) -> (Self, SwappableLimiterCellHandle) { let limiter = SwappableLimiterCell::empty(); let handle = limiter.handle(); ( Self { inner, limiter, read_wait: PendingWait::new(RateLimiterDirection::Read), write_wait: PendingWait::new(RateLimiterDirection::Write), }, handle, ) } } impl RateLimitedStream { fn poll_read_nowait( &mut self, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>, ) -> Poll> { let prev_remaining = buf.remaining(); let ret = Pin::new(&mut self.inner).poll_read(cx, buf); if ret.is_ready() { let read = prev_remaining - buf.remaining(); // Read completed, reset waiter self.read_wait.reset(read); } ret } } impl RateLimitedStream { fn poll_write_nowait( &mut self, cx: &mut Context<'_>, data: &[u8], ) -> Poll> { let ret = Pin::new(&mut self.inner).poll_write(cx, data); if let Poll::Ready(result) = &ret { // Write completed, reset waiter self.write_wait.reset(match result { Ok(bytes_written) => *bytes_written, Err(_) => 0, }); } ret } } impl AsyncRead for RateLimitedStream { fn poll_read( self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>, ) -> Poll> { let this = self.get_mut(); let to_read = buf.remaining(); if to_read == 0 { // ready check return Pin::new(&mut this.inner).poll_read(cx, buf); } match this.read_wait.poll_rate_limit(&mut this.limiter, cx) { Poll::Ready(Ok(())) => this.poll_read_nowait(cx, buf), x => x, } } } impl AsyncWrite for RateLimitedStream { fn poll_write( self: Pin<&mut Self>, cx: &mut Context<'_>, data: &[u8], ) -> Poll> { let this = self.get_mut(); if data.is_empty() { // A ready check from tokio return Pin::new(&mut this.inner).poll_write(cx, data); } match this.write_wait.poll_rate_limit(&mut this.limiter, cx) { Poll::Ready(Ok(())) => this.poll_write_nowait(cx, data), Poll::Ready(Err(e)) => Poll::Ready(Err(e)), Poll::Pending => Poll::Pending, } } fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { Pin::new(&mut self.get_mut().inner).poll_flush(cx) } fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { Pin::new(&mut self.get_mut().inner).poll_shutdown(cx) } } ================================================ FILE: warpgate-core/src/rate_limiting/swappable_cell.rs ================================================ use governor::clock::QuantaInstant; use tokio::sync::watch; use warpgate_common::WarpgateError; use super::shared_limiter::SharedWarpgateRateLimiter; use super::RateLimiterDirection; pub struct SwappableLimiterCellHandle { sender: watch::Sender>, } impl SwappableLimiterCellHandle { pub fn replace(&self, limiter: Option) { let _ = self.sender.send(limiter); } } /// Houses a replaceable shared reference to a `WarpgateRateLimiter` rate limiter. /// Cloning the cell will provide a copy that is synchronized with the original #[derive(Clone)] pub struct SwappableLimiterCell { inner: Option, receiver: watch::Receiver>, sender: watch::Sender>, } impl SwappableLimiterCell { pub fn empty() -> Self { let (sender, receiver) = watch::channel(None); Self { inner: None, receiver, sender, } } pub fn handle(&self) -> SwappableLimiterCellHandle { SwappableLimiterCellHandle { sender: self.sender.clone(), } } fn _maybe_update(&mut self) { let _ref = self.receiver.borrow_and_update(); if _ref.has_changed() { self.inner = _ref.as_ref().cloned(); } } #[must_use = "Must use the Instant to wait"] pub fn bytes_ready_at( &mut self, direction: RateLimiterDirection, bytes: usize, ) -> Result, WarpgateError> { self._maybe_update(); let Some(ref rate_limiter) = self.inner else { return Ok(None); }; rate_limiter.lock().bytes_ready_at(direction, bytes) } } ================================================ FILE: warpgate-core/src/recordings/mod.rs ================================================ use std::collections::HashMap; use std::fmt::Debug; use std::path::{Path, PathBuf}; use std::sync::Arc; use bytes::Bytes; use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter}; use serde::Serialize; use tokio::sync::{broadcast, Mutex}; use tracing::*; use uuid::Uuid; use warpgate_common::helpers::fs::secure_directory; use warpgate_common::{GlobalParams, RecordingsConfig, SessionId, WarpgateConfig}; use warpgate_db_entities::Recording::{self, RecordingKind}; mod terminal; mod traffic; mod writer; pub use terminal::*; pub use traffic::*; pub use writer::RecordingWriter; #[derive(thiserror::Error, Debug)] pub enum Error { #[error("I/O: {0}")] Io(#[from] std::io::Error), #[error("Database: {0}")] Database(#[from] sea_orm::DbErr), #[error("Failed to serialize a recording item: {0}")] Serialization(#[from] serde_json::Error), #[error("Writer is closed")] Closed, #[error("Disabled")] Disabled, #[error("Invalid recording path")] InvalidPath, } pub type Result = std::result::Result; pub trait Recorder { fn kind() -> RecordingKind; fn new(writer: RecordingWriter) -> Self; } pub struct SessionRecordings { db: Arc>, path: PathBuf, config: RecordingsConfig, live: Arc>>>, params: GlobalParams, } impl SessionRecordings { pub fn new( db: Arc>, config: &WarpgateConfig, params: &GlobalParams, ) -> Result { let mut path = params.paths_relative_to().clone(); path.push(&config.store.recordings.path); if config.store.recordings.enable { std::fs::create_dir_all(&path)?; if params.should_secure_files() { secure_directory(&path)?; } } Ok(Self { db, config: config.store.recordings.clone(), path, live: Arc::new(Mutex::new(HashMap::new())), params: params.clone(), }) } /// Starting a recording with the same name again will append to it pub async fn start( &mut self, id: &SessionId, name: Option, metadata: M, ) -> Result where T: Recorder, M: Serialize + Debug, { if !self.config.enable { return Err(Error::Disabled); } let name = name.unwrap_or_else(|| Uuid::new_v4().to_string()); let path = self.path_for(id, &name); tokio::fs::create_dir_all(&path.parent().ok_or(Error::InvalidPath)?).await?; let model = { let db = self.db.lock().await; let existing = Recording::Entity::find() .filter( Recording::Column::SessionId .eq(*id) .and(Recording::Column::Name.eq(name.clone())) .and(Recording::Column::Kind.eq(T::kind())), ) .one(&*db) .await?; match existing { Some(e) => e, None => { info!(%name, ?metadata, path=?path, "Recording session {}", id); 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.clone()), kind: Set(T::kind()), metadata: Set(serde_json::to_string(&metadata)?), ..Default::default() }; values.insert(&*db).await.map_err(Error::Database)? } } }; let writer = RecordingWriter::new( path, model, self.db.clone(), self.live.clone(), &self.params, ) .await?; Ok(T::new(writer)) } pub async fn subscribe_live(&self, id: &Uuid) -> Option> { let live = self.live.lock().await; live.get(id).map(|sender| sender.subscribe()) } pub async fn remove(&self, session_id: &SessionId, name: &str) -> Result<()> { let path = self.path_for(session_id, name); tokio::fs::remove_file(&path).await?; if let Some(parent) = path.parent() { if tokio::fs::read_dir(parent) .await? .next_entry() .await? .is_none() { tokio::fs::remove_dir(parent).await?; } } Ok(()) } pub fn path_for>(&self, session_id: &SessionId, name: P) -> PathBuf { self.path.join(session_id.to_string()).join(&name) } } ================================================ FILE: warpgate-core/src/recordings/terminal.rs ================================================ use bytes::Bytes; use serde::{Deserialize, Serialize}; use tokio::time::Instant; use warpgate_db_entities::Recording::RecordingKind; use super::writer::RecordingWriter; use super::{Error, Recorder, Result}; #[derive(Serialize)] #[serde(untagged)] pub enum AsciiCast { Header { time: f32, version: u32, width: u32, height: u32, title: String, }, Output(f32, String, String), } #[derive(Serialize, Deserialize, Debug, Default)] pub enum TerminalRecordingStreamId { Input, #[default] Output, Error, } impl TerminalRecordingStreamId { pub fn from_usual_fd_number(fd: u8) -> Option { match fd { 0 => Some(TerminalRecordingStreamId::Input), 1 => Some(TerminalRecordingStreamId::Output), 2 => Some(TerminalRecordingStreamId::Error), _ => None, } } } #[derive(Serialize, Deserialize, Debug)] #[serde(untagged)] pub enum TerminalRecordingItem { Data { time: f32, #[serde(default)] stream: TerminalRecordingStreamId, #[serde(with = "warpgate_common::helpers::serde_base64")] data: Bytes, }, PtyResize { time: f32, cols: u32, rows: u32, }, } impl From for AsciiCast { fn from(item: TerminalRecordingItem) -> Self { match item { TerminalRecordingItem::Data { time, stream, data } => AsciiCast::Output( time, match stream { TerminalRecordingStreamId::Input => "i".to_string(), TerminalRecordingStreamId::Output => "o".to_string(), TerminalRecordingStreamId::Error => "e".to_string(), }, String::from_utf8_lossy(&data[..]).to_string(), ), TerminalRecordingItem::PtyResize { time, cols, rows } => AsciiCast::Header { time, version: 2, width: cols, height: rows, title: "".to_string(), }, } } } 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 mut serialized_item = serde_json::to_vec(&item).map_err(Error::Serialization)?; serialized_item.push(b'\n'); self.writer.write(&serialized_item).await?; Ok(()) } pub async fn write(&mut self, stream: TerminalRecordingStreamId, data: &[u8]) -> Result<()> { self.write_item(&TerminalRecordingItem::Data { time: self.get_time(), stream, data: Bytes::from(data.to_vec()), }) .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(), } } } ================================================ FILE: warpgate-core/src/recordings/traffic.rs ================================================ 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 enum TrafficConnectionParams { Tcp { src_addr: Ipv4Addr, src_port: u16, dst_addr: Ipv4Addr, dst_port: u16, }, Socket { socket_path: String, }, } 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?; Ok(()) } pub async fn write_rx(&mut self, data: &[u8]) -> Result<()> { debug!("connection {:?} data rx {:?}", 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(&self, f: F) -> Result where F: FnOnce(packet::ip::v4::Builder) -> Result, { match self.params { TrafficConnectionParams::Socket { .. } => f(packet::ip::v4::Builder::default() .protocol(packet::ip::Protocol::Tcp)? .source(Ipv4Addr::UNSPECIFIED)? .destination(Ipv4Addr::BROADCAST)?), TrafficConnectionParams::Tcp { src_addr, dst_addr, .. } => f(packet::ip::v4::Builder::default() .protocol(packet::ip::Protocol::Tcp)? .source(src_addr)? .destination(dst_addr)?), } } fn ip_packet_rx(&self, f: F) -> Result where F: FnOnce(packet::ip::v4::Builder) -> Result, { match self.params { TrafficConnectionParams::Socket { .. } => f(packet::ip::v4::Builder::default() .protocol(packet::ip::Protocol::Tcp)? .source(Ipv4Addr::BROADCAST)? .destination(Ipv4Addr::UNSPECIFIED)?), TrafficConnectionParams::Tcp { src_addr, dst_addr, .. } => f(packet::ip::v4::Builder::default() .protocol(packet::ip::Protocol::Tcp)? .source(dst_addr)? .destination(src_addr)?), } } fn tcp_packet_tx(&self, f: F) -> Result where F: FnOnce(packet::tcp::Builder) -> Result, { self.ip_packet_tx(|b| match self.params { TrafficConnectionParams::Socket { .. } => f(b.tcp()?.source(0)?.destination(0)?), TrafficConnectionParams::Tcp { src_port, dst_port, .. } => f(b.tcp()?.source(src_port)?.destination(dst_port)?), }) } fn tcp_packet_rx(&self, f: F) -> Result where F: FnOnce(packet::tcp::Builder) -> Result, { self.ip_packet_rx(|b| match self.params { TrafficConnectionParams::Socket { .. } => f(b.tcp()?.source(0)?.destination(0)?), TrafficConnectionParams::Tcp { src_port, dst_port, .. } => f(b.tcp()?.source(dst_port)?.destination(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()) })?, )) } } ================================================ FILE: warpgate-core/src/recordings/writer.rs ================================================ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; use bytes::Bytes; use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait}; use tokio::fs::File; use tokio::io::{AsyncWriteExt, BufWriter}; use tokio::sync::{broadcast, mpsc, Mutex}; use tracing::*; use uuid::Uuid; use warpgate_common::helpers::fs::secure_file; use warpgate_common::{try_block, GlobalParams}; use warpgate_db_entities::Recording; use super::{Error, Result}; #[derive(Clone)] pub struct RecordingWriter { sender: mpsc::Sender, live_sender: broadcast::Sender, drop_signal: mpsc::Sender<()>, } impl RecordingWriter { pub(crate) async fn new( path: PathBuf, model: Recording::Model, db: Arc>, live: Arc>>>, params: &GlobalParams, ) -> Result { let file = File::options() .append(true) .create(true) .open(&path) .await?; if params.should_secure_files() { secure_file(&path)?; } let mut writer = BufWriter::new(file); let (sender, mut receiver) = mpsc::channel::(1024); let (drop_signal, mut drop_receiver) = mpsc::channel(1); let live_sender = broadcast::channel(128).0; { let mut live = live.lock().await; live.insert(model.id, live_sender.clone()); } tokio::spawn({ let live = live.clone(); let id = model.id; async move { let _ = drop_receiver.recv().await; let mut live = live.lock().await; live.remove(&id); } }); tokio::spawn(async move { try_block!(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>(()) } catch (error: anyhow::Error) { error!(%error, ?path, "Failed to write recording"); }); try_block!(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>(()) } catch (error: anyhow::Error) { error!(%error, ?path, "Failed to write recording"); }); }); Ok(RecordingWriter { sender, live_sender, drop_signal, }) } pub async fn write(&mut self, data: &[u8]) -> Result<()> { let data = Bytes::from(data.to_vec()); self.sender .send(data.clone()) .await .map_err(|_| Error::Closed)?; let _ = self.live_sender.send(data); Ok(()) } } impl Drop for RecordingWriter { fn drop(&mut self) { let signal = std::mem::replace(&mut self.drop_signal, mpsc::channel(1).0); tokio::spawn(async move { signal.send(()).await }); } } ================================================ FILE: warpgate-core/src/services.rs ================================================ use std::sync::Arc; use std::time::Duration; use anyhow::Result; use sea_orm::DatabaseConnection; use tokio::sync::Mutex; use warpgate_common::{GlobalParams, WarpgateConfig}; use crate::db::{connect_to_db, populate_db}; use crate::rate_limiting::RateLimiterRegistry; use crate::recordings::SessionRecordings; use crate::{AuthStateStore, ConfigProviderEnum, DatabaseConfigProvider, State}; #[derive(Clone)] pub struct Services { pub db: Arc>, pub recordings: Arc>, pub config: Arc>, pub state: Arc>, pub config_provider: Arc>, pub auth_state_store: Arc>, pub admin_token: Arc>>, pub rate_limiter_registry: Arc>, pub global_params: Arc, } impl Services { pub async fn new( mut config: WarpgateConfig, admin_token: Option, params: GlobalParams, ) -> Result { let mut db = connect_to_db(&config, ¶ms).await?; populate_db(&mut db, &mut config).await?; let db = Arc::new(Mutex::new(db)); let recordings = SessionRecordings::new(db.clone(), &config, ¶ms)?; let recordings = Arc::new(Mutex::new(recordings)); let config = Arc::new(Mutex::new(config)); let config_provider = Arc::new(Mutex::new(DatabaseConfigProvider::new(&db).await.into())); let auth_state_store = Arc::new(Mutex::new(AuthStateStore::new(config_provider.clone()))); tokio::spawn({ let auth_state_store = auth_state_store.clone(); async move { loop { auth_state_store.lock().await.vacuum().await; tokio::time::sleep(Duration::from_secs(60)).await; } } }); let mut rate_limiter_registry = RateLimiterRegistry::new(db.clone()); rate_limiter_registry.refresh().await?; let rate_limiter_registry = Arc::new(Mutex::new(rate_limiter_registry)); Ok(Self { db: db.clone(), recordings, config: config.clone(), state: State::new(&db, &rate_limiter_registry)?, rate_limiter_registry, config_provider, auth_state_store, admin_token: Arc::new(Mutex::new(admin_token)), global_params: Arc::new(params), }) } } ================================================ FILE: warpgate-core/src/state.rs ================================================ use std::collections::HashMap; use std::net::SocketAddr; use std::sync::Arc; use anyhow::{Context, Result}; use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait}; use tokio::sync::{broadcast, Mutex}; use tracing::*; use uuid::Uuid; use warpgate_common::auth::AuthStateUserInfo; use warpgate_common::{ProtocolName, SessionId, Target, WarpgateError}; use warpgate_db_entities::Session; use crate::rate_limiting::{RateLimiterRegistry, RateLimiterStackHandle}; use crate::{SessionHandle, WarpgateServerHandle}; pub struct State { pub sessions: HashMap>>, db: Arc>, rate_limiter_registry: Arc>, change_sender: broadcast::Sender<()>, } impl State { pub fn new( db: &Arc>, rate_limiter_registry: &Arc>, ) -> Result>, WarpgateError> { let sender = broadcast::channel(2).0; Ok(Arc::new(Mutex::new(Self { sessions: HashMap::new(), db: db.clone(), rate_limiter_registry: rate_limiter_registry.clone(), change_sender: sender, }))) } pub async fn register_session( this: &Arc>, protocol: &ProtocolName, state: SessionStateInit, ) -> Result>, WarpgateError> { let this_copy = this.clone(); let mut _self = this.lock().await; let id = uuid::Uuid::new_v4(); let state = Arc::new(Mutex::new(SessionState::new( state, _self.change_sender.clone(), ))); _self.sessions.insert(id, state.clone()); { use sea_orm::ActiveValue::Set; let values = Session::ActiveModel { id: Set(id), started: Set(chrono::Utc::now()), remote_address: Set(state .lock() .await .remote_address .map(|x| x.to_string()) .unwrap_or_else(|| "".to_string())), protocol: Set(protocol.to_string()), ..Default::default() }; let db = _self.db.lock().await; values .insert(&*db) .await .context("Error inserting session") .map_err(WarpgateError::from)?; } let _ = _self.change_sender.send(()); Ok(Arc::new(Mutex::new(WarpgateServerHandle::new( id, _self.db.clone(), this_copy, state, _self.rate_limiter_registry.clone(), )?))) } pub fn subscribe(&mut self) -> broadcast::Receiver<()> { self.change_sender.subscribe() } 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"); } let _ = self.change_sender.send(()); } 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: Option, pub user_info: Option, pub target: Option, pub handle: Box, change_sender: broadcast::Sender<()>, pub rate_limiter_handles: Vec, } pub struct SessionStateInit { pub remote_address: Option, pub handle: Box, } impl SessionState { fn new(init: SessionStateInit, change_sender: broadcast::Sender<()>) -> Self { SessionState { remote_address: init.remote_address, user_info: None, target: None, handle: init.handle, change_sender, rate_limiter_handles: vec![], } } pub fn emit_change(&self) { let _ = self.change_sender.send(()); } } ================================================ FILE: warpgate-database-protocols/Cargo.toml ================================================ [package] name = "warpgate-database-protocols" version = "0.22.0" description = "Core of SQLx, the rust SQL toolkit. Just the database protocol parts." license = "MIT OR Apache-2.0" edition = "2021" authors = [ "Ryan Leckey ", "Austin Bonander ", "Chloe Ross ", "Daniel Akhterov ", ] [dependencies] tokio.workspace = true bitflags = { version = "2", default-features = false } bytes.workspace = true futures-core = { version = "0.3", default-features = false } futures-util = { version = "0.3", default-features = false, features = [ "alloc", "sink", ] } memchr = { version = "2.5", default-features = false } thiserror.workspace = true ================================================ FILE: warpgate-database-protocols/README.md ================================================ This is an extract from sqlx-core with Encode/Decode impls added for server-side packet flow ================================================ FILE: warpgate-database-protocols/src/error.rs ================================================ //! Types for working with errors produced by SQLx. use std::borrow::Cow; use std::error::Error as StdError; use std::fmt::Display; use std::io; use std::result::Result as StdResult; /// A specialized `Result` type for SQLx. pub type Result = StdResult; // Convenience type alias for usage within SQLx. // Do not make this type public. pub type BoxDynError = Box; /// An unexpected `NULL` was encountered during decoding. /// /// Returned from [`Row::get`](crate::row::Row::get) if the value from the database is `NULL`, /// and you are not decoding into an `Option`. #[derive(thiserror::Error, Debug)] #[error("unexpected null; try decoding as an `Option`")] pub struct UnexpectedNullError; /// Represents all the ways a method can fail within SQLx. #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum Error { /// Error occurred while parsing a connection string. #[error("error with configuration: {0}")] Configuration(#[source] BoxDynError), /// Error returned from the database. #[error("error returned from database: {0}")] Database(#[source] Box), /// Error communicating with the database backend. #[error("error communicating with database: {0}")] Io(#[from] io::Error), /// Error occurred while attempting to establish a TLS connection. #[error("error occurred while attempting to establish a TLS connection: {0}")] Tls(#[source] BoxDynError), /// Unexpected or invalid data encountered while communicating with the database. /// /// This should indicate there is a programming error in a SQLx driver or there /// is something corrupted with the connection to the database itself. #[error("encountered unexpected or invalid data: {0}")] Protocol(String), /// No rows returned by a query that expected to return at least one row. #[error("no rows returned by a query that expected to return at least one row")] RowNotFound, /// Type in query doesn't exist. Likely due to typo or missing user type. #[error("type named {type_name} not found")] TypeNotFound { type_name: String }, /// Column index was out of bounds. #[error("column index out of bounds: the len is {len}, but the index is {index}")] ColumnIndexOutOfBounds { index: usize, len: usize }, /// No column found for the given name. #[error("no column found for name: {0}")] ColumnNotFound(String), /// Error occurred while decoding a value from a specific column. #[error("error occurred while decoding column {index}: {source}")] ColumnDecode { index: String, #[source] source: BoxDynError, }, /// Error occurred while decoding a value. #[error("error occurred while decoding: {0}")] Decode(#[source] BoxDynError), /// A [`Pool::acquire`] timed out due to connections not becoming available or /// because another task encountered too many errors while trying to open a new connection. /// /// [`Pool::acquire`]: crate::pool::Pool::acquire #[error("pool timed out while waiting for an open connection")] PoolTimedOut, /// [`Pool::close`] was called while we were waiting in [`Pool::acquire`]. /// /// [`Pool::acquire`]: crate::pool::Pool::acquire /// [`Pool::close`]: crate::pool::Pool::close #[error("attempted to acquire a connection on a closed pool")] PoolClosed, /// A background worker has crashed. #[error("attempted to communicate with a crashed background worker")] WorkerCrashed, } impl StdError for Box {} impl Error { #[allow(dead_code)] #[inline] pub(crate) fn protocol(err: impl Display) -> Self { Error::Protocol(err.to_string()) } #[allow(dead_code)] #[inline] pub(crate) fn config(err: impl StdError + Send + Sync + 'static) -> Self { Error::Configuration(err.into()) } } /// An error that was returned from the database. pub trait DatabaseError: 'static + Send + Sync + StdError { /// The primary, human-readable error message. fn message(&self) -> &str; /// The (SQLSTATE) code for the error. fn code(&self) -> Option> { None } #[doc(hidden)] fn as_error(&self) -> &(dyn StdError + Send + Sync + 'static); #[doc(hidden)] fn as_error_mut(&mut self) -> &mut (dyn StdError + Send + Sync + 'static); #[doc(hidden)] fn into_error(self: Box) -> Box; #[doc(hidden)] fn is_transient_in_connect_phase(&self) -> bool { false } /// Returns the name of the constraint that triggered the error, if applicable. /// If the error was caused by a conflict of a unique index, this will be the index name. /// /// ### Note /// Currently only populated by the Postgres driver. fn constraint(&self) -> Option<&str> { None } } impl dyn DatabaseError { /// Downcast a reference to this generic database error to a specific /// database error type. #[inline] pub fn try_downcast_ref(&self) -> Option<&E> { self.as_error().downcast_ref() } /// Downcast this generic database error to a specific database error type. #[inline] pub fn try_downcast(self: Box) -> StdResult, Box> { if self.as_error().is::() { #[allow(clippy::unwrap_used)] Ok(self.into_error().downcast().unwrap()) } else { Err(self) } } } impl From for Error where E: DatabaseError, { #[inline] fn from(error: E) -> Self { Error::Database(Box::new(error)) } } // Format an error message as a `Protocol` error #[macro_export] macro_rules! err_protocol { ($expr:expr) => { $crate::error::Error::Protocol($expr.into()) }; ($fmt:expr, $($arg:tt)*) => { $crate::error::Error::Protocol(format!($fmt, $($arg)*)) }; } ================================================ FILE: warpgate-database-protocols/src/io/buf.rs ================================================ use std::str::from_utf8; use bytes::{Buf, Bytes}; use memchr::memchr; use crate::err_protocol; use crate::error::Error; pub trait BufExt: Buf { // Read a nul-terminated byte sequence fn get_bytes_nul(&mut self) -> Result; // Read a byte sequence of the exact length fn get_bytes(&mut self, len: usize) -> Bytes; // Read a nul-terminated string fn get_str_nul(&mut self) -> Result; // Read a string of the exact length fn get_str(&mut self, len: usize) -> Result; } impl BufExt for Bytes { fn get_bytes_nul(&mut self) -> Result { let nul = memchr(b'\0', self).ok_or_else(|| err_protocol!("expected NUL in byte sequence"))?; let v = self.slice(0..nul); self.advance(nul + 1); Ok(v) } fn get_bytes(&mut self, len: usize) -> Bytes { let v = self.slice(..len); self.advance(len); v } fn get_str_nul(&mut self) -> Result { self.get_bytes_nul().and_then(|bytes| { from_utf8(&bytes) .map(ToOwned::to_owned) .map_err(|err| err_protocol!("{}", err)) }) } fn get_str(&mut self, len: usize) -> Result { let v = from_utf8(&self[..len]) .map_err(|err| err_protocol!("{}", err)) .map(ToOwned::to_owned)?; self.advance(len); Ok(v) } } ================================================ FILE: warpgate-database-protocols/src/io/buf_mut.rs ================================================ use bytes::BufMut; pub trait BufMutExt: BufMut { fn put_str_nul(&mut self, s: &str); } impl BufMutExt for Vec { fn put_str_nul(&mut self, s: &str) { self.extend(s.as_bytes()); self.push(0); } } ================================================ FILE: warpgate-database-protocols/src/io/buf_stream.rs ================================================ #![allow(dead_code)] use std::io; use std::io::Cursor; use std::ops::{Deref, DerefMut}; use bytes::BytesMut; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite}; use crate::error::Error; use crate::io::decode::Decode; use crate::io::encode::Encode; use crate::io::write_and_flush::WriteAndFlush; pub struct BufStream where S: AsyncRead + AsyncWrite + Unpin, { pub(crate) stream: S, // writes with `write` to the underlying stream are buffered // this can be flushed with `flush` pub(crate) wbuf: Vec, // we read into the read buffer using 100% safe code rbuf: BytesMut, } impl BufStream where S: AsyncRead + AsyncWrite + Unpin, { pub fn new(stream: S) -> Self { Self { stream, wbuf: Vec::with_capacity(512), rbuf: BytesMut::with_capacity(4096), } } pub fn write<'en, T>(&mut self, value: T) where T: Encode<'en, ()>, { self.write_with(value, ()) } pub fn write_with<'en, T, C>(&mut self, value: T, context: C) where T: Encode<'en, C>, { value.encode_with(&mut self.wbuf, context); } pub fn flush(&mut self) -> WriteAndFlush<'_, S> { WriteAndFlush { stream: &mut self.stream, buf: Cursor::new(&mut self.wbuf), } } pub async fn read<'de, T>(&mut self, cnt: usize) -> Result where T: Decode<'de, ()>, { self.read_with(cnt, ()).await } pub async fn read_with<'de, T, C>(&mut self, cnt: usize, context: C) -> Result where T: Decode<'de, C>, { T::decode_with(self.read_raw(cnt).await?.freeze(), context) } pub async fn read_raw(&mut self, cnt: usize) -> Result { read_raw_into(&mut self.stream, &mut self.rbuf, cnt).await?; let buf = self.rbuf.split_to(cnt); Ok(buf) } pub async fn read_raw_into(&mut self, buf: &mut BytesMut, cnt: usize) -> Result<(), Error> { read_raw_into(&mut self.stream, buf, cnt).await } pub fn take(self) -> S { self.stream } } impl Deref for BufStream where S: AsyncRead + AsyncWrite + Unpin, { type Target = S; fn deref(&self) -> &Self::Target { &self.stream } } impl DerefMut for BufStream where S: AsyncRead + AsyncWrite + Unpin, { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.stream } } // Holds a buffer which has been temporarily extended, so that // we can read into it. Automatically shrinks the buffer back // down if the read is cancelled. struct BufTruncator<'a> { buf: &'a mut BytesMut, filled_len: usize, } impl<'a> BufTruncator<'a> { fn new(buf: &'a mut BytesMut) -> Self { let filled_len = buf.len(); Self { buf, filled_len } } fn reserve(&mut self, space: usize) { self.buf.resize(self.filled_len + space, 0); } async fn read(&mut self, stream: &mut S) -> Result { let n = stream.read(&mut self.buf[self.filled_len..]).await?; self.filled_len += n; Ok(n) } fn is_full(&self) -> bool { self.filled_len >= self.buf.len() } } impl Drop for BufTruncator<'_> { fn drop(&mut self) { self.buf.truncate(self.filled_len); } } async fn read_raw_into( stream: &mut S, buf: &mut BytesMut, cnt: usize, ) -> Result<(), Error> { let mut buf = BufTruncator::new(buf); buf.reserve(cnt); while !buf.is_full() { let n = buf.read(stream).await?; if n == 0 { // a zero read when we had space in the read buffer // should be treated as an EOF // and an unexpected EOF means the server told us to go away return Err(io::Error::from(io::ErrorKind::ConnectionAborted).into()); } } Ok(()) } ================================================ FILE: warpgate-database-protocols/src/io/decode.rs ================================================ use bytes::Bytes; use crate::error::Error; pub trait Decode<'de, Context = ()> where Self: Sized, { fn decode(buf: Bytes) -> Result where Self: Decode<'de, ()>, { Self::decode_with(buf, ()) } fn decode_with(buf: Bytes, context: Context) -> Result; } impl Decode<'_> for Bytes { fn decode_with(buf: Bytes, _: ()) -> Result { Ok(buf) } } impl Decode<'_> for () { fn decode_with(_: Bytes, _: ()) -> Result<(), Error> { Ok(()) } } ================================================ FILE: warpgate-database-protocols/src/io/encode.rs ================================================ pub trait Encode<'en, Context = ()> { fn encode(&self, buf: &mut Vec) where Self: Encode<'en, ()>, { self.encode_with(buf, ()); } fn encode_with(&self, buf: &mut Vec, context: Context); } impl Encode<'_, C> for &'_ [u8] { fn encode_with(&self, buf: &mut Vec, _: C) { buf.extend_from_slice(self); } } ================================================ FILE: warpgate-database-protocols/src/io/mod.rs ================================================ mod buf; mod buf_mut; mod buf_stream; mod decode; mod encode; mod write_and_flush; pub use buf::BufExt; pub use buf_mut::BufMutExt; pub use buf_stream::BufStream; pub use decode::Decode; pub use encode::Encode; ================================================ FILE: warpgate-database-protocols/src/io/write_and_flush.rs ================================================ use std::io::{BufRead, Cursor}; use std::pin::Pin; use std::task::{Context, Poll}; use futures_core::Future; use futures_util::ready; use tokio::io::AsyncWrite; use crate::error::Error; // Atomic operation that writes the full buffer to the stream, flushes the stream, and then // clears the buffer (even if either of the two previous operations failed). pub struct WriteAndFlush<'a, S> { pub(super) stream: &'a mut S, pub(super) buf: Cursor<&'a mut Vec>, } impl Future for WriteAndFlush<'_, S> { type Output = Result<(), Error>; fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { let Self { ref mut stream, ref mut buf, } = *self; loop { let read = buf.fill_buf()?; if !read.is_empty() { let written = ready!(Pin::new(&mut *stream).poll_write(cx, read)?); buf.consume(written); } else { break; } } Pin::new(stream).poll_flush(cx).map_err(Error::Io) } } impl Drop for WriteAndFlush<'_, S> { fn drop(&mut self) { // clear the buffer regardless of whether the flush succeeded or not self.buf.get_mut().clear(); } } ================================================ FILE: warpgate-database-protocols/src/lib.rs ================================================ #![allow(dead_code, clippy::indexing_slicing)] pub mod io; pub mod mysql; #[macro_use] pub mod error; ================================================ FILE: warpgate-database-protocols/src/mysql/collation.rs ================================================ use std::str::FromStr; use crate::error::Error; #[allow(non_camel_case_types)] #[derive(Copy, Clone)] pub(crate) enum CharSet { armscii8, ascii, big5, binary, cp1250, cp1251, cp1256, cp1257, cp850, cp852, cp866, cp932, dec8, eucjpms, euckr, gb18030, gb2312, gbk, geostd8, greek, hebrew, hp8, keybcs2, koi8r, koi8u, latin1, latin2, latin5, latin7, macce, macroman, sjis, swe7, tis620, ucs2, ujis, utf16, utf16le, utf32, utf8, utf8mb4, } impl CharSet { pub(crate) fn as_str(&self) -> &'static str { match self { CharSet::armscii8 => "armscii8", CharSet::ascii => "ascii", CharSet::big5 => "big5", CharSet::binary => "binary", CharSet::cp1250 => "cp1250", CharSet::cp1251 => "cp1251", CharSet::cp1256 => "cp1256", CharSet::cp1257 => "cp1257", CharSet::cp850 => "cp850", CharSet::cp852 => "cp852", CharSet::cp866 => "cp866", CharSet::cp932 => "cp932", CharSet::dec8 => "dec8", CharSet::eucjpms => "eucjpms", CharSet::euckr => "euckr", CharSet::gb18030 => "gb18030", CharSet::gb2312 => "gb2312", CharSet::gbk => "gbk", CharSet::geostd8 => "geostd8", CharSet::greek => "greek", CharSet::hebrew => "hebrew", CharSet::hp8 => "hp8", CharSet::keybcs2 => "keybcs2", CharSet::koi8r => "koi8r", CharSet::koi8u => "koi8u", CharSet::latin1 => "latin1", CharSet::latin2 => "latin2", CharSet::latin5 => "latin5", CharSet::latin7 => "latin7", CharSet::macce => "macce", CharSet::macroman => "macroman", CharSet::sjis => "sjis", CharSet::swe7 => "swe7", CharSet::tis620 => "tis620", CharSet::ucs2 => "ucs2", CharSet::ujis => "ujis", CharSet::utf16 => "utf16", CharSet::utf16le => "utf16le", CharSet::utf32 => "utf32", CharSet::utf8 => "utf8", CharSet::utf8mb4 => "utf8mb4", } } pub(crate) fn default_collation(&self) -> Collation { match self { CharSet::armscii8 => Collation::armscii8_general_ci, CharSet::ascii => Collation::ascii_general_ci, CharSet::big5 => Collation::big5_chinese_ci, CharSet::binary => Collation::binary, CharSet::cp1250 => Collation::cp1250_general_ci, CharSet::cp1251 => Collation::cp1251_general_ci, CharSet::cp1256 => Collation::cp1256_general_ci, CharSet::cp1257 => Collation::cp1257_general_ci, CharSet::cp850 => Collation::cp850_general_ci, CharSet::cp852 => Collation::cp852_general_ci, CharSet::cp866 => Collation::cp866_general_ci, CharSet::cp932 => Collation::cp932_japanese_ci, CharSet::dec8 => Collation::dec8_swedish_ci, CharSet::eucjpms => Collation::eucjpms_japanese_ci, CharSet::euckr => Collation::euckr_korean_ci, CharSet::gb18030 => Collation::gb18030_chinese_ci, CharSet::gb2312 => Collation::gb2312_chinese_ci, CharSet::gbk => Collation::gbk_chinese_ci, CharSet::geostd8 => Collation::geostd8_general_ci, CharSet::greek => Collation::greek_general_ci, CharSet::hebrew => Collation::hebrew_general_ci, CharSet::hp8 => Collation::hp8_english_ci, CharSet::keybcs2 => Collation::keybcs2_general_ci, CharSet::koi8r => Collation::koi8r_general_ci, CharSet::koi8u => Collation::koi8u_general_ci, CharSet::latin1 => Collation::latin1_swedish_ci, CharSet::latin2 => Collation::latin2_general_ci, CharSet::latin5 => Collation::latin5_turkish_ci, CharSet::latin7 => Collation::latin7_general_ci, CharSet::macce => Collation::macce_general_ci, CharSet::macroman => Collation::macroman_general_ci, CharSet::sjis => Collation::sjis_japanese_ci, CharSet::swe7 => Collation::swe7_swedish_ci, CharSet::tis620 => Collation::tis620_thai_ci, CharSet::ucs2 => Collation::ucs2_general_ci, CharSet::ujis => Collation::ujis_japanese_ci, CharSet::utf16 => Collation::utf16_general_ci, CharSet::utf16le => Collation::utf16le_general_ci, CharSet::utf32 => Collation::utf32_general_ci, CharSet::utf8 => Collation::utf8_unicode_ci, CharSet::utf8mb4 => Collation::utf8mb4_unicode_ci, } } } impl FromStr for CharSet { type Err = Error; fn from_str(char_set: &str) -> Result { Ok(match char_set { "armscii8" => CharSet::armscii8, "ascii" => CharSet::ascii, "big5" => CharSet::big5, "binary" => CharSet::binary, "cp1250" => CharSet::cp1250, "cp1251" => CharSet::cp1251, "cp1256" => CharSet::cp1256, "cp1257" => CharSet::cp1257, "cp850" => CharSet::cp850, "cp852" => CharSet::cp852, "cp866" => CharSet::cp866, "cp932" => CharSet::cp932, "dec8" => CharSet::dec8, "eucjpms" => CharSet::eucjpms, "euckr" => CharSet::euckr, "gb18030" => CharSet::gb18030, "gb2312" => CharSet::gb2312, "gbk" => CharSet::gbk, "geostd8" => CharSet::geostd8, "greek" => CharSet::greek, "hebrew" => CharSet::hebrew, "hp8" => CharSet::hp8, "keybcs2" => CharSet::keybcs2, "koi8r" => CharSet::koi8r, "koi8u" => CharSet::koi8u, "latin1" => CharSet::latin1, "latin2" => CharSet::latin2, "latin5" => CharSet::latin5, "latin7" => CharSet::latin7, "macce" => CharSet::macce, "macroman" => CharSet::macroman, "sjis" => CharSet::sjis, "swe7" => CharSet::swe7, "tis620" => CharSet::tis620, "ucs2" => CharSet::ucs2, "ujis" => CharSet::ujis, "utf16" => CharSet::utf16, "utf16le" => CharSet::utf16le, "utf32" => CharSet::utf32, "utf8" => CharSet::utf8, "utf8mb4" => CharSet::utf8mb4, _ => { return Err(Error::Configuration( format!("unsupported MySQL charset: {char_set}").into(), )); } }) } } #[derive(Copy, Clone)] #[allow(non_camel_case_types)] #[repr(u8)] pub(crate) enum Collation { armscii8_bin = 64, armscii8_general_ci = 32, ascii_bin = 65, ascii_general_ci = 11, big5_bin = 84, big5_chinese_ci = 1, binary = 63, cp1250_bin = 66, cp1250_croatian_ci = 44, cp1250_czech_cs = 34, cp1250_general_ci = 26, cp1250_polish_ci = 99, cp1251_bin = 50, cp1251_bulgarian_ci = 14, cp1251_general_ci = 51, cp1251_general_cs = 52, cp1251_ukrainian_ci = 23, cp1256_bin = 67, cp1256_general_ci = 57, cp1257_bin = 58, cp1257_general_ci = 59, cp1257_lithuanian_ci = 29, cp850_bin = 80, cp850_general_ci = 4, cp852_bin = 81, cp852_general_ci = 40, cp866_bin = 68, cp866_general_ci = 36, cp932_bin = 96, cp932_japanese_ci = 95, dec8_bin = 69, dec8_swedish_ci = 3, eucjpms_bin = 98, eucjpms_japanese_ci = 97, euckr_bin = 85, euckr_korean_ci = 19, gb18030_bin = 249, gb18030_chinese_ci = 248, gb18030_unicode_520_ci = 250, gb2312_bin = 86, gb2312_chinese_ci = 24, gbk_bin = 87, gbk_chinese_ci = 28, geostd8_bin = 93, geostd8_general_ci = 92, greek_bin = 70, greek_general_ci = 25, hebrew_bin = 71, hebrew_general_ci = 16, hp8_bin = 72, hp8_english_ci = 6, keybcs2_bin = 73, keybcs2_general_ci = 37, koi8r_bin = 74, koi8r_general_ci = 7, koi8u_bin = 75, koi8u_general_ci = 22, latin1_bin = 47, latin1_danish_ci = 15, latin1_general_ci = 48, latin1_general_cs = 49, latin1_german1_ci = 5, latin1_german2_ci = 31, latin1_spanish_ci = 94, latin1_swedish_ci = 8, latin2_bin = 77, latin2_croatian_ci = 27, latin2_czech_cs = 2, latin2_general_ci = 9, latin2_hungarian_ci = 21, latin5_bin = 78, latin5_turkish_ci = 30, latin7_bin = 79, latin7_estonian_cs = 20, latin7_general_ci = 41, latin7_general_cs = 42, macce_bin = 43, macce_general_ci = 38, macroman_bin = 53, macroman_general_ci = 39, sjis_bin = 88, sjis_japanese_ci = 13, swe7_bin = 82, swe7_swedish_ci = 10, tis620_bin = 89, tis620_thai_ci = 18, ucs2_bin = 90, ucs2_croatian_ci = 149, ucs2_czech_ci = 138, ucs2_danish_ci = 139, ucs2_esperanto_ci = 145, ucs2_estonian_ci = 134, ucs2_general_ci = 35, ucs2_general_mysql500_ci = 159, ucs2_german2_ci = 148, ucs2_hungarian_ci = 146, ucs2_icelandic_ci = 129, ucs2_latvian_ci = 130, ucs2_lithuanian_ci = 140, ucs2_persian_ci = 144, ucs2_polish_ci = 133, ucs2_roman_ci = 143, ucs2_romanian_ci = 131, ucs2_sinhala_ci = 147, ucs2_slovak_ci = 141, ucs2_slovenian_ci = 132, ucs2_spanish_ci = 135, ucs2_spanish2_ci = 142, ucs2_swedish_ci = 136, ucs2_turkish_ci = 137, ucs2_unicode_520_ci = 150, ucs2_unicode_ci = 128, ucs2_vietnamese_ci = 151, ujis_bin = 91, ujis_japanese_ci = 12, utf16_bin = 55, utf16_croatian_ci = 122, utf16_czech_ci = 111, utf16_danish_ci = 112, utf16_esperanto_ci = 118, utf16_estonian_ci = 107, utf16_general_ci = 54, utf16_german2_ci = 121, utf16_hungarian_ci = 119, utf16_icelandic_ci = 102, utf16_latvian_ci = 103, utf16_lithuanian_ci = 113, utf16_persian_ci = 117, utf16_polish_ci = 106, utf16_roman_ci = 116, utf16_romanian_ci = 104, utf16_sinhala_ci = 120, utf16_slovak_ci = 114, utf16_slovenian_ci = 105, utf16_spanish_ci = 108, utf16_spanish2_ci = 115, utf16_swedish_ci = 109, utf16_turkish_ci = 110, utf16_unicode_520_ci = 123, utf16_unicode_ci = 101, utf16_vietnamese_ci = 124, utf16le_bin = 62, utf16le_general_ci = 56, utf32_bin = 61, utf32_croatian_ci = 181, utf32_czech_ci = 170, utf32_danish_ci = 171, utf32_esperanto_ci = 177, utf32_estonian_ci = 166, utf32_general_ci = 60, utf32_german2_ci = 180, utf32_hungarian_ci = 178, utf32_icelandic_ci = 161, utf32_latvian_ci = 162, utf32_lithuanian_ci = 172, utf32_persian_ci = 176, utf32_polish_ci = 165, utf32_roman_ci = 175, utf32_romanian_ci = 163, utf32_sinhala_ci = 179, utf32_slovak_ci = 173, utf32_slovenian_ci = 164, utf32_spanish_ci = 167, utf32_spanish2_ci = 174, utf32_swedish_ci = 168, utf32_turkish_ci = 169, utf32_unicode_520_ci = 182, utf32_unicode_ci = 160, utf32_vietnamese_ci = 183, utf8_bin = 83, utf8_croatian_ci = 213, utf8_czech_ci = 202, utf8_danish_ci = 203, utf8_esperanto_ci = 209, utf8_estonian_ci = 198, utf8_general_ci = 33, utf8_general_mysql500_ci = 223, utf8_german2_ci = 212, utf8_hungarian_ci = 210, utf8_icelandic_ci = 193, utf8_latvian_ci = 194, utf8_lithuanian_ci = 204, utf8_persian_ci = 208, utf8_polish_ci = 197, utf8_roman_ci = 207, utf8_romanian_ci = 195, utf8_sinhala_ci = 211, utf8_slovak_ci = 205, utf8_slovenian_ci = 196, utf8_spanish_ci = 199, utf8_spanish2_ci = 206, utf8_swedish_ci = 200, utf8_tolower_ci = 76, utf8_turkish_ci = 201, utf8_unicode_520_ci = 214, utf8_unicode_ci = 192, utf8_vietnamese_ci = 215, utf8mb4_0900_ai_ci = 255, utf8mb4_bin = 46, utf8mb4_croatian_ci = 245, utf8mb4_czech_ci = 234, utf8mb4_danish_ci = 235, utf8mb4_esperanto_ci = 241, utf8mb4_estonian_ci = 230, utf8mb4_general_ci = 45, utf8mb4_german2_ci = 244, utf8mb4_hungarian_ci = 242, utf8mb4_icelandic_ci = 225, utf8mb4_latvian_ci = 226, utf8mb4_lithuanian_ci = 236, utf8mb4_persian_ci = 240, utf8mb4_polish_ci = 229, utf8mb4_roman_ci = 239, utf8mb4_romanian_ci = 227, utf8mb4_sinhala_ci = 243, utf8mb4_slovak_ci = 237, utf8mb4_slovenian_ci = 228, utf8mb4_spanish_ci = 231, utf8mb4_spanish2_ci = 238, utf8mb4_swedish_ci = 232, utf8mb4_turkish_ci = 233, utf8mb4_unicode_520_ci = 246, utf8mb4_unicode_ci = 224, utf8mb4_vietnamese_ci = 247, } impl Collation { pub(crate) fn as_str(&self) -> &'static str { match self { Collation::armscii8_bin => "armscii8_bin", Collation::armscii8_general_ci => "armscii8_general_ci", Collation::ascii_bin => "ascii_bin", Collation::ascii_general_ci => "ascii_general_ci", Collation::big5_bin => "big5_bin", Collation::big5_chinese_ci => "big5_chinese_ci", Collation::binary => "binary", Collation::cp1250_bin => "cp1250_bin", Collation::cp1250_croatian_ci => "cp1250_croatian_ci", Collation::cp1250_czech_cs => "cp1250_czech_cs", Collation::cp1250_general_ci => "cp1250_general_ci", Collation::cp1250_polish_ci => "cp1250_polish_ci", Collation::cp1251_bin => "cp1251_bin", Collation::cp1251_bulgarian_ci => "cp1251_bulgarian_ci", Collation::cp1251_general_ci => "cp1251_general_ci", Collation::cp1251_general_cs => "cp1251_general_cs", Collation::cp1251_ukrainian_ci => "cp1251_ukrainian_ci", Collation::cp1256_bin => "cp1256_bin", Collation::cp1256_general_ci => "cp1256_general_ci", Collation::cp1257_bin => "cp1257_bin", Collation::cp1257_general_ci => "cp1257_general_ci", Collation::cp1257_lithuanian_ci => "cp1257_lithuanian_ci", Collation::cp850_bin => "cp850_bin", Collation::cp850_general_ci => "cp850_general_ci", Collation::cp852_bin => "cp852_bin", Collation::cp852_general_ci => "cp852_general_ci", Collation::cp866_bin => "cp866_bin", Collation::cp866_general_ci => "cp866_general_ci", Collation::cp932_bin => "cp932_bin", Collation::cp932_japanese_ci => "cp932_japanese_ci", Collation::dec8_bin => "dec8_bin", Collation::dec8_swedish_ci => "dec8_swedish_ci", Collation::eucjpms_bin => "eucjpms_bin", Collation::eucjpms_japanese_ci => "eucjpms_japanese_ci", Collation::euckr_bin => "euckr_bin", Collation::euckr_korean_ci => "euckr_korean_ci", Collation::gb18030_bin => "gb18030_bin", Collation::gb18030_chinese_ci => "gb18030_chinese_ci", Collation::gb18030_unicode_520_ci => "gb18030_unicode_520_ci", Collation::gb2312_bin => "gb2312_bin", Collation::gb2312_chinese_ci => "gb2312_chinese_ci", Collation::gbk_bin => "gbk_bin", Collation::gbk_chinese_ci => "gbk_chinese_ci", Collation::geostd8_bin => "geostd8_bin", Collation::geostd8_general_ci => "geostd8_general_ci", Collation::greek_bin => "greek_bin", Collation::greek_general_ci => "greek_general_ci", Collation::hebrew_bin => "hebrew_bin", Collation::hebrew_general_ci => "hebrew_general_ci", Collation::hp8_bin => "hp8_bin", Collation::hp8_english_ci => "hp8_english_ci", Collation::keybcs2_bin => "keybcs2_bin", Collation::keybcs2_general_ci => "keybcs2_general_ci", Collation::koi8r_bin => "koi8r_bin", Collation::koi8r_general_ci => "koi8r_general_ci", Collation::koi8u_bin => "koi8u_bin", Collation::koi8u_general_ci => "koi8u_general_ci", Collation::latin1_bin => "latin1_bin", Collation::latin1_danish_ci => "latin1_danish_ci", Collation::latin1_general_ci => "latin1_general_ci", Collation::latin1_general_cs => "latin1_general_cs", Collation::latin1_german1_ci => "latin1_german1_ci", Collation::latin1_german2_ci => "latin1_german2_ci", Collation::latin1_spanish_ci => "latin1_spanish_ci", Collation::latin1_swedish_ci => "latin1_swedish_ci", Collation::latin2_bin => "latin2_bin", Collation::latin2_croatian_ci => "latin2_croatian_ci", Collation::latin2_czech_cs => "latin2_czech_cs", Collation::latin2_general_ci => "latin2_general_ci", Collation::latin2_hungarian_ci => "latin2_hungarian_ci", Collation::latin5_bin => "latin5_bin", Collation::latin5_turkish_ci => "latin5_turkish_ci", Collation::latin7_bin => "latin7_bin", Collation::latin7_estonian_cs => "latin7_estonian_cs", Collation::latin7_general_ci => "latin7_general_ci", Collation::latin7_general_cs => "latin7_general_cs", Collation::macce_bin => "macce_bin", Collation::macce_general_ci => "macce_general_ci", Collation::macroman_bin => "macroman_bin", Collation::macroman_general_ci => "macroman_general_ci", Collation::sjis_bin => "sjis_bin", Collation::sjis_japanese_ci => "sjis_japanese_ci", Collation::swe7_bin => "swe7_bin", Collation::swe7_swedish_ci => "swe7_swedish_ci", Collation::tis620_bin => "tis620_bin", Collation::tis620_thai_ci => "tis620_thai_ci", Collation::ucs2_bin => "ucs2_bin", Collation::ucs2_croatian_ci => "ucs2_croatian_ci", Collation::ucs2_czech_ci => "ucs2_czech_ci", Collation::ucs2_danish_ci => "ucs2_danish_ci", Collation::ucs2_esperanto_ci => "ucs2_esperanto_ci", Collation::ucs2_estonian_ci => "ucs2_estonian_ci", Collation::ucs2_general_ci => "ucs2_general_ci", Collation::ucs2_general_mysql500_ci => "ucs2_general_mysql500_ci", Collation::ucs2_german2_ci => "ucs2_german2_ci", Collation::ucs2_hungarian_ci => "ucs2_hungarian_ci", Collation::ucs2_icelandic_ci => "ucs2_icelandic_ci", Collation::ucs2_latvian_ci => "ucs2_latvian_ci", Collation::ucs2_lithuanian_ci => "ucs2_lithuanian_ci", Collation::ucs2_persian_ci => "ucs2_persian_ci", Collation::ucs2_polish_ci => "ucs2_polish_ci", Collation::ucs2_roman_ci => "ucs2_roman_ci", Collation::ucs2_romanian_ci => "ucs2_romanian_ci", Collation::ucs2_sinhala_ci => "ucs2_sinhala_ci", Collation::ucs2_slovak_ci => "ucs2_slovak_ci", Collation::ucs2_slovenian_ci => "ucs2_slovenian_ci", Collation::ucs2_spanish_ci => "ucs2_spanish_ci", Collation::ucs2_spanish2_ci => "ucs2_spanish2_ci", Collation::ucs2_swedish_ci => "ucs2_swedish_ci", Collation::ucs2_turkish_ci => "ucs2_turkish_ci", Collation::ucs2_unicode_520_ci => "ucs2_unicode_520_ci", Collation::ucs2_unicode_ci => "ucs2_unicode_ci", Collation::ucs2_vietnamese_ci => "ucs2_vietnamese_ci", Collation::ujis_bin => "ujis_bin", Collation::ujis_japanese_ci => "ujis_japanese_ci", Collation::utf16_bin => "utf16_bin", Collation::utf16_croatian_ci => "utf16_croatian_ci", Collation::utf16_czech_ci => "utf16_czech_ci", Collation::utf16_danish_ci => "utf16_danish_ci", Collation::utf16_esperanto_ci => "utf16_esperanto_ci", Collation::utf16_estonian_ci => "utf16_estonian_ci", Collation::utf16_general_ci => "utf16_general_ci", Collation::utf16_german2_ci => "utf16_german2_ci", Collation::utf16_hungarian_ci => "utf16_hungarian_ci", Collation::utf16_icelandic_ci => "utf16_icelandic_ci", Collation::utf16_latvian_ci => "utf16_latvian_ci", Collation::utf16_lithuanian_ci => "utf16_lithuanian_ci", Collation::utf16_persian_ci => "utf16_persian_ci", Collation::utf16_polish_ci => "utf16_polish_ci", Collation::utf16_roman_ci => "utf16_roman_ci", Collation::utf16_romanian_ci => "utf16_romanian_ci", Collation::utf16_sinhala_ci => "utf16_sinhala_ci", Collation::utf16_slovak_ci => "utf16_slovak_ci", Collation::utf16_slovenian_ci => "utf16_slovenian_ci", Collation::utf16_spanish_ci => "utf16_spanish_ci", Collation::utf16_spanish2_ci => "utf16_spanish2_ci", Collation::utf16_swedish_ci => "utf16_swedish_ci", Collation::utf16_turkish_ci => "utf16_turkish_ci", Collation::utf16_unicode_520_ci => "utf16_unicode_520_ci", Collation::utf16_unicode_ci => "utf16_unicode_ci", Collation::utf16_vietnamese_ci => "utf16_vietnamese_ci", Collation::utf16le_bin => "utf16le_bin", Collation::utf16le_general_ci => "utf16le_general_ci", Collation::utf32_bin => "utf32_bin", Collation::utf32_croatian_ci => "utf32_croatian_ci", Collation::utf32_czech_ci => "utf32_czech_ci", Collation::utf32_danish_ci => "utf32_danish_ci", Collation::utf32_esperanto_ci => "utf32_esperanto_ci", Collation::utf32_estonian_ci => "utf32_estonian_ci", Collation::utf32_general_ci => "utf32_general_ci", Collation::utf32_german2_ci => "utf32_german2_ci", Collation::utf32_hungarian_ci => "utf32_hungarian_ci", Collation::utf32_icelandic_ci => "utf32_icelandic_ci", Collation::utf32_latvian_ci => "utf32_latvian_ci", Collation::utf32_lithuanian_ci => "utf32_lithuanian_ci", Collation::utf32_persian_ci => "utf32_persian_ci", Collation::utf32_polish_ci => "utf32_polish_ci", Collation::utf32_roman_ci => "utf32_roman_ci", Collation::utf32_romanian_ci => "utf32_romanian_ci", Collation::utf32_sinhala_ci => "utf32_sinhala_ci", Collation::utf32_slovak_ci => "utf32_slovak_ci", Collation::utf32_slovenian_ci => "utf32_slovenian_ci", Collation::utf32_spanish_ci => "utf32_spanish_ci", Collation::utf32_spanish2_ci => "utf32_spanish2_ci", Collation::utf32_swedish_ci => "utf32_swedish_ci", Collation::utf32_turkish_ci => "utf32_turkish_ci", Collation::utf32_unicode_520_ci => "utf32_unicode_520_ci", Collation::utf32_unicode_ci => "utf32_unicode_ci", Collation::utf32_vietnamese_ci => "utf32_vietnamese_ci", Collation::utf8_bin => "utf8_bin", Collation::utf8_croatian_ci => "utf8_croatian_ci", Collation::utf8_czech_ci => "utf8_czech_ci", Collation::utf8_danish_ci => "utf8_danish_ci", Collation::utf8_esperanto_ci => "utf8_esperanto_ci", Collation::utf8_estonian_ci => "utf8_estonian_ci", Collation::utf8_general_ci => "utf8_general_ci", Collation::utf8_general_mysql500_ci => "utf8_general_mysql500_ci", Collation::utf8_german2_ci => "utf8_german2_ci", Collation::utf8_hungarian_ci => "utf8_hungarian_ci", Collation::utf8_icelandic_ci => "utf8_icelandic_ci", Collation::utf8_latvian_ci => "utf8_latvian_ci", Collation::utf8_lithuanian_ci => "utf8_lithuanian_ci", Collation::utf8_persian_ci => "utf8_persian_ci", Collation::utf8_polish_ci => "utf8_polish_ci", Collation::utf8_roman_ci => "utf8_roman_ci", Collation::utf8_romanian_ci => "utf8_romanian_ci", Collation::utf8_sinhala_ci => "utf8_sinhala_ci", Collation::utf8_slovak_ci => "utf8_slovak_ci", Collation::utf8_slovenian_ci => "utf8_slovenian_ci", Collation::utf8_spanish_ci => "utf8_spanish_ci", Collation::utf8_spanish2_ci => "utf8_spanish2_ci", Collation::utf8_swedish_ci => "utf8_swedish_ci", Collation::utf8_tolower_ci => "utf8_tolower_ci", Collation::utf8_turkish_ci => "utf8_turkish_ci", Collation::utf8_unicode_520_ci => "utf8_unicode_520_ci", Collation::utf8_unicode_ci => "utf8_unicode_ci", Collation::utf8_vietnamese_ci => "utf8_vietnamese_ci", Collation::utf8mb4_0900_ai_ci => "utf8mb4_0900_ai_ci", Collation::utf8mb4_bin => "utf8mb4_bin", Collation::utf8mb4_croatian_ci => "utf8mb4_croatian_ci", Collation::utf8mb4_czech_ci => "utf8mb4_czech_ci", Collation::utf8mb4_danish_ci => "utf8mb4_danish_ci", Collation::utf8mb4_esperanto_ci => "utf8mb4_esperanto_ci", Collation::utf8mb4_estonian_ci => "utf8mb4_estonian_ci", Collation::utf8mb4_general_ci => "utf8mb4_general_ci", Collation::utf8mb4_german2_ci => "utf8mb4_german2_ci", Collation::utf8mb4_hungarian_ci => "utf8mb4_hungarian_ci", Collation::utf8mb4_icelandic_ci => "utf8mb4_icelandic_ci", Collation::utf8mb4_latvian_ci => "utf8mb4_latvian_ci", Collation::utf8mb4_lithuanian_ci => "utf8mb4_lithuanian_ci", Collation::utf8mb4_persian_ci => "utf8mb4_persian_ci", Collation::utf8mb4_polish_ci => "utf8mb4_polish_ci", Collation::utf8mb4_roman_ci => "utf8mb4_roman_ci", Collation::utf8mb4_romanian_ci => "utf8mb4_romanian_ci", Collation::utf8mb4_sinhala_ci => "utf8mb4_sinhala_ci", Collation::utf8mb4_slovak_ci => "utf8mb4_slovak_ci", Collation::utf8mb4_slovenian_ci => "utf8mb4_slovenian_ci", Collation::utf8mb4_spanish_ci => "utf8mb4_spanish_ci", Collation::utf8mb4_spanish2_ci => "utf8mb4_spanish2_ci", Collation::utf8mb4_swedish_ci => "utf8mb4_swedish_ci", Collation::utf8mb4_turkish_ci => "utf8mb4_turkish_ci", Collation::utf8mb4_unicode_520_ci => "utf8mb4_unicode_520_ci", Collation::utf8mb4_unicode_ci => "utf8mb4_unicode_ci", Collation::utf8mb4_vietnamese_ci => "utf8mb4_vietnamese_ci", } } } // Handshake packet have only 1 byte for collation_id. // So we can't use collations with ID > 255. impl FromStr for Collation { type Err = Error; fn from_str(collation: &str) -> Result { Ok(match collation { "big5_chinese_ci" => Collation::big5_chinese_ci, "swe7_swedish_ci" => Collation::swe7_swedish_ci, "utf16_unicode_ci" => Collation::utf16_unicode_ci, "utf16_icelandic_ci" => Collation::utf16_icelandic_ci, "utf16_latvian_ci" => Collation::utf16_latvian_ci, "utf16_romanian_ci" => Collation::utf16_romanian_ci, "utf16_slovenian_ci" => Collation::utf16_slovenian_ci, "utf16_polish_ci" => Collation::utf16_polish_ci, "utf16_estonian_ci" => Collation::utf16_estonian_ci, "utf16_spanish_ci" => Collation::utf16_spanish_ci, "utf16_swedish_ci" => Collation::utf16_swedish_ci, "ascii_general_ci" => Collation::ascii_general_ci, "utf16_turkish_ci" => Collation::utf16_turkish_ci, "utf16_czech_ci" => Collation::utf16_czech_ci, "utf16_danish_ci" => Collation::utf16_danish_ci, "utf16_lithuanian_ci" => Collation::utf16_lithuanian_ci, "utf16_slovak_ci" => Collation::utf16_slovak_ci, "utf16_spanish2_ci" => Collation::utf16_spanish2_ci, "utf16_roman_ci" => Collation::utf16_roman_ci, "utf16_persian_ci" => Collation::utf16_persian_ci, "utf16_esperanto_ci" => Collation::utf16_esperanto_ci, "utf16_hungarian_ci" => Collation::utf16_hungarian_ci, "ujis_japanese_ci" => Collation::ujis_japanese_ci, "utf16_sinhala_ci" => Collation::utf16_sinhala_ci, "utf16_german2_ci" => Collation::utf16_german2_ci, "utf16_croatian_ci" => Collation::utf16_croatian_ci, "utf16_unicode_520_ci" => Collation::utf16_unicode_520_ci, "utf16_vietnamese_ci" => Collation::utf16_vietnamese_ci, "ucs2_unicode_ci" => Collation::ucs2_unicode_ci, "ucs2_icelandic_ci" => Collation::ucs2_icelandic_ci, "sjis_japanese_ci" => Collation::sjis_japanese_ci, "ucs2_latvian_ci" => Collation::ucs2_latvian_ci, "ucs2_romanian_ci" => Collation::ucs2_romanian_ci, "ucs2_slovenian_ci" => Collation::ucs2_slovenian_ci, "ucs2_polish_ci" => Collation::ucs2_polish_ci, "ucs2_estonian_ci" => Collation::ucs2_estonian_ci, "ucs2_spanish_ci" => Collation::ucs2_spanish_ci, "ucs2_swedish_ci" => Collation::ucs2_swedish_ci, "ucs2_turkish_ci" => Collation::ucs2_turkish_ci, "ucs2_czech_ci" => Collation::ucs2_czech_ci, "ucs2_danish_ci" => Collation::ucs2_danish_ci, "cp1251_bulgarian_ci" => Collation::cp1251_bulgarian_ci, "ucs2_lithuanian_ci" => Collation::ucs2_lithuanian_ci, "ucs2_slovak_ci" => Collation::ucs2_slovak_ci, "ucs2_spanish2_ci" => Collation::ucs2_spanish2_ci, "ucs2_roman_ci" => Collation::ucs2_roman_ci, "ucs2_persian_ci" => Collation::ucs2_persian_ci, "ucs2_esperanto_ci" => Collation::ucs2_esperanto_ci, "ucs2_hungarian_ci" => Collation::ucs2_hungarian_ci, "ucs2_sinhala_ci" => Collation::ucs2_sinhala_ci, "ucs2_german2_ci" => Collation::ucs2_german2_ci, "ucs2_croatian_ci" => Collation::ucs2_croatian_ci, "latin1_danish_ci" => Collation::latin1_danish_ci, "ucs2_unicode_520_ci" => Collation::ucs2_unicode_520_ci, "ucs2_vietnamese_ci" => Collation::ucs2_vietnamese_ci, "ucs2_general_mysql500_ci" => Collation::ucs2_general_mysql500_ci, "hebrew_general_ci" => Collation::hebrew_general_ci, "utf32_unicode_ci" => Collation::utf32_unicode_ci, "utf32_icelandic_ci" => Collation::utf32_icelandic_ci, "utf32_latvian_ci" => Collation::utf32_latvian_ci, "utf32_romanian_ci" => Collation::utf32_romanian_ci, "utf32_slovenian_ci" => Collation::utf32_slovenian_ci, "utf32_polish_ci" => Collation::utf32_polish_ci, "utf32_estonian_ci" => Collation::utf32_estonian_ci, "utf32_spanish_ci" => Collation::utf32_spanish_ci, "utf32_swedish_ci" => Collation::utf32_swedish_ci, "utf32_turkish_ci" => Collation::utf32_turkish_ci, "utf32_czech_ci" => Collation::utf32_czech_ci, "utf32_danish_ci" => Collation::utf32_danish_ci, "utf32_lithuanian_ci" => Collation::utf32_lithuanian_ci, "utf32_slovak_ci" => Collation::utf32_slovak_ci, "utf32_spanish2_ci" => Collation::utf32_spanish2_ci, "utf32_roman_ci" => Collation::utf32_roman_ci, "utf32_persian_ci" => Collation::utf32_persian_ci, "utf32_esperanto_ci" => Collation::utf32_esperanto_ci, "utf32_hungarian_ci" => Collation::utf32_hungarian_ci, "utf32_sinhala_ci" => Collation::utf32_sinhala_ci, "tis620_thai_ci" => Collation::tis620_thai_ci, "utf32_german2_ci" => Collation::utf32_german2_ci, "utf32_croatian_ci" => Collation::utf32_croatian_ci, "utf32_unicode_520_ci" => Collation::utf32_unicode_520_ci, "utf32_vietnamese_ci" => Collation::utf32_vietnamese_ci, "euckr_korean_ci" => Collation::euckr_korean_ci, "utf8_unicode_ci" => Collation::utf8_unicode_ci, "utf8_icelandic_ci" => Collation::utf8_icelandic_ci, "utf8_latvian_ci" => Collation::utf8_latvian_ci, "utf8_romanian_ci" => Collation::utf8_romanian_ci, "utf8_slovenian_ci" => Collation::utf8_slovenian_ci, "utf8_polish_ci" => Collation::utf8_polish_ci, "utf8_estonian_ci" => Collation::utf8_estonian_ci, "utf8_spanish_ci" => Collation::utf8_spanish_ci, "latin2_czech_cs" => Collation::latin2_czech_cs, "latin7_estonian_cs" => Collation::latin7_estonian_cs, "utf8_swedish_ci" => Collation::utf8_swedish_ci, "utf8_turkish_ci" => Collation::utf8_turkish_ci, "utf8_czech_ci" => Collation::utf8_czech_ci, "utf8_danish_ci" => Collation::utf8_danish_ci, "utf8_lithuanian_ci" => Collation::utf8_lithuanian_ci, "utf8_slovak_ci" => Collation::utf8_slovak_ci, "utf8_spanish2_ci" => Collation::utf8_spanish2_ci, "utf8_roman_ci" => Collation::utf8_roman_ci, "utf8_persian_ci" => Collation::utf8_persian_ci, "utf8_esperanto_ci" => Collation::utf8_esperanto_ci, "latin2_hungarian_ci" => Collation::latin2_hungarian_ci, "utf8_hungarian_ci" => Collation::utf8_hungarian_ci, "utf8_sinhala_ci" => Collation::utf8_sinhala_ci, "utf8_german2_ci" => Collation::utf8_german2_ci, "utf8_croatian_ci" => Collation::utf8_croatian_ci, "utf8_unicode_520_ci" => Collation::utf8_unicode_520_ci, "utf8_vietnamese_ci" => Collation::utf8_vietnamese_ci, "koi8u_general_ci" => Collation::koi8u_general_ci, "utf8_general_mysql500_ci" => Collation::utf8_general_mysql500_ci, "utf8mb4_unicode_ci" => Collation::utf8mb4_unicode_ci, "utf8mb4_icelandic_ci" => Collation::utf8mb4_icelandic_ci, "utf8mb4_latvian_ci" => Collation::utf8mb4_latvian_ci, "utf8mb4_romanian_ci" => Collation::utf8mb4_romanian_ci, "utf8mb4_slovenian_ci" => Collation::utf8mb4_slovenian_ci, "utf8mb4_polish_ci" => Collation::utf8mb4_polish_ci, "cp1251_ukrainian_ci" => Collation::cp1251_ukrainian_ci, "utf8mb4_estonian_ci" => Collation::utf8mb4_estonian_ci, "utf8mb4_spanish_ci" => Collation::utf8mb4_spanish_ci, "utf8mb4_swedish_ci" => Collation::utf8mb4_swedish_ci, "utf8mb4_turkish_ci" => Collation::utf8mb4_turkish_ci, "utf8mb4_czech_ci" => Collation::utf8mb4_czech_ci, "utf8mb4_danish_ci" => Collation::utf8mb4_danish_ci, "utf8mb4_lithuanian_ci" => Collation::utf8mb4_lithuanian_ci, "utf8mb4_slovak_ci" => Collation::utf8mb4_slovak_ci, "utf8mb4_spanish2_ci" => Collation::utf8mb4_spanish2_ci, "utf8mb4_roman_ci" => Collation::utf8mb4_roman_ci, "gb2312_chinese_ci" => Collation::gb2312_chinese_ci, "utf8mb4_persian_ci" => Collation::utf8mb4_persian_ci, "utf8mb4_esperanto_ci" => Collation::utf8mb4_esperanto_ci, "utf8mb4_hungarian_ci" => Collation::utf8mb4_hungarian_ci, "utf8mb4_sinhala_ci" => Collation::utf8mb4_sinhala_ci, "utf8mb4_german2_ci" => Collation::utf8mb4_german2_ci, "utf8mb4_croatian_ci" => Collation::utf8mb4_croatian_ci, "utf8mb4_unicode_520_ci" => Collation::utf8mb4_unicode_520_ci, "utf8mb4_vietnamese_ci" => Collation::utf8mb4_vietnamese_ci, "gb18030_chinese_ci" => Collation::gb18030_chinese_ci, "gb18030_bin" => Collation::gb18030_bin, "greek_general_ci" => Collation::greek_general_ci, "gb18030_unicode_520_ci" => Collation::gb18030_unicode_520_ci, "utf8mb4_0900_ai_ci" => Collation::utf8mb4_0900_ai_ci, "cp1250_general_ci" => Collation::cp1250_general_ci, "latin2_croatian_ci" => Collation::latin2_croatian_ci, "gbk_chinese_ci" => Collation::gbk_chinese_ci, "cp1257_lithuanian_ci" => Collation::cp1257_lithuanian_ci, "dec8_swedish_ci" => Collation::dec8_swedish_ci, "latin5_turkish_ci" => Collation::latin5_turkish_ci, "latin1_german2_ci" => Collation::latin1_german2_ci, "armscii8_general_ci" => Collation::armscii8_general_ci, "utf8_general_ci" => Collation::utf8_general_ci, "cp1250_czech_cs" => Collation::cp1250_czech_cs, "ucs2_general_ci" => Collation::ucs2_general_ci, "cp866_general_ci" => Collation::cp866_general_ci, "keybcs2_general_ci" => Collation::keybcs2_general_ci, "macce_general_ci" => Collation::macce_general_ci, "macroman_general_ci" => Collation::macroman_general_ci, "cp850_general_ci" => Collation::cp850_general_ci, "cp852_general_ci" => Collation::cp852_general_ci, "latin7_general_ci" => Collation::latin7_general_ci, "latin7_general_cs" => Collation::latin7_general_cs, "macce_bin" => Collation::macce_bin, "cp1250_croatian_ci" => Collation::cp1250_croatian_ci, "utf8mb4_general_ci" => Collation::utf8mb4_general_ci, "utf8mb4_bin" => Collation::utf8mb4_bin, "latin1_bin" => Collation::latin1_bin, "latin1_general_ci" => Collation::latin1_general_ci, "latin1_general_cs" => Collation::latin1_general_cs, "latin1_german1_ci" => Collation::latin1_german1_ci, "cp1251_bin" => Collation::cp1251_bin, "cp1251_general_ci" => Collation::cp1251_general_ci, "cp1251_general_cs" => Collation::cp1251_general_cs, "macroman_bin" => Collation::macroman_bin, "utf16_general_ci" => Collation::utf16_general_ci, "utf16_bin" => Collation::utf16_bin, "utf16le_general_ci" => Collation::utf16le_general_ci, "cp1256_general_ci" => Collation::cp1256_general_ci, "cp1257_bin" => Collation::cp1257_bin, "cp1257_general_ci" => Collation::cp1257_general_ci, "hp8_english_ci" => Collation::hp8_english_ci, "utf32_general_ci" => Collation::utf32_general_ci, "utf32_bin" => Collation::utf32_bin, "utf16le_bin" => Collation::utf16le_bin, "binary" => Collation::binary, "armscii8_bin" => Collation::armscii8_bin, "ascii_bin" => Collation::ascii_bin, "cp1250_bin" => Collation::cp1250_bin, "cp1256_bin" => Collation::cp1256_bin, "cp866_bin" => Collation::cp866_bin, "dec8_bin" => Collation::dec8_bin, "koi8r_general_ci" => Collation::koi8r_general_ci, "greek_bin" => Collation::greek_bin, "hebrew_bin" => Collation::hebrew_bin, "hp8_bin" => Collation::hp8_bin, "keybcs2_bin" => Collation::keybcs2_bin, "koi8r_bin" => Collation::koi8r_bin, "koi8u_bin" => Collation::koi8u_bin, "utf8_tolower_ci" => Collation::utf8_tolower_ci, "latin2_bin" => Collation::latin2_bin, "latin5_bin" => Collation::latin5_bin, "latin7_bin" => Collation::latin7_bin, "latin1_swedish_ci" => Collation::latin1_swedish_ci, "cp850_bin" => Collation::cp850_bin, "cp852_bin" => Collation::cp852_bin, "swe7_bin" => Collation::swe7_bin, "utf8_bin" => Collation::utf8_bin, "big5_bin" => Collation::big5_bin, "euckr_bin" => Collation::euckr_bin, "gb2312_bin" => Collation::gb2312_bin, "gbk_bin" => Collation::gbk_bin, "sjis_bin" => Collation::sjis_bin, "tis620_bin" => Collation::tis620_bin, "latin2_general_ci" => Collation::latin2_general_ci, "ucs2_bin" => Collation::ucs2_bin, "ujis_bin" => Collation::ujis_bin, "geostd8_general_ci" => Collation::geostd8_general_ci, "geostd8_bin" => Collation::geostd8_bin, "latin1_spanish_ci" => Collation::latin1_spanish_ci, "cp932_japanese_ci" => Collation::cp932_japanese_ci, "cp932_bin" => Collation::cp932_bin, "eucjpms_japanese_ci" => Collation::eucjpms_japanese_ci, "eucjpms_bin" => Collation::eucjpms_bin, "cp1250_polish_ci" => Collation::cp1250_polish_ci, _ => { return Err(Error::Configuration( format!("unsupported MySQL collation: {collation}").into(), )); } }) } } ================================================ FILE: warpgate-database-protocols/src/mysql/io/buf.rs ================================================ use bytes::{Buf, Bytes}; use crate::error::Error; use crate::io::BufExt; pub trait MySqlBufExt: Buf { // Read a length-encoded integer. // NOTE: 0xfb or NULL is only returned for binary value encoding to indicate NULL. // NOTE: 0xff is only returned during a result set to indicate ERR. // fn get_uint_lenenc(&mut self) -> u64; // Read a length-encoded string. fn get_str_lenenc(&mut self) -> Result; // Read a length-encoded byte sequence. fn get_bytes_lenenc(&mut self) -> Bytes; } impl MySqlBufExt for Bytes { fn get_uint_lenenc(&mut self) -> u64 { match self.get_u8() { 0xfc => u64::from(self.get_u16_le()), 0xfd => self.get_uint_le(3), 0xfe => self.get_u64_le(), v => u64::from(v), } } fn get_str_lenenc(&mut self) -> Result { let size = self.get_uint_lenenc(); self.get_str(size as usize) } fn get_bytes_lenenc(&mut self) -> Bytes { let size = self.get_uint_lenenc(); self.split_to(size as usize) } } ================================================ FILE: warpgate-database-protocols/src/mysql/io/buf_mut.rs ================================================ use bytes::BufMut; pub trait MySqlBufMutExt: BufMut { fn put_uint_lenenc(&mut self, v: u64); fn put_str_lenenc(&mut self, v: &str); fn put_bytes_lenenc(&mut self, v: &[u8]); } impl MySqlBufMutExt for Vec { fn put_uint_lenenc(&mut self, v: u64) { // https://dev.mysql.com/doc/internals/en/integer.html // https://mariadb.com/kb/en/library/protocol-data-types/#length-encoded-integers if v < 251 { self.push(v as u8); } else if v < 0x1_00_00 { self.push(0xfc); self.extend((v as u16).to_le_bytes()); } else if v < 0x1_00_00_00 { self.push(0xfd); self.extend(&(v as u32).to_le_bytes()[..3]); } else { self.push(0xfe); self.extend(v.to_le_bytes()); } } fn put_str_lenenc(&mut self, v: &str) { self.put_bytes_lenenc(v.as_bytes()); } fn put_bytes_lenenc(&mut self, v: &[u8]) { self.put_uint_lenenc(v.len() as u64); self.extend(v); } } #[test] fn test_encodes_int_lenenc_u8() { let mut buf = Vec::with_capacity(1024); buf.put_uint_lenenc(0xFA_u64); assert_eq!(&buf[..], b"\xFA"); } #[test] fn test_encodes_int_lenenc_u16() { let mut buf = Vec::with_capacity(1024); buf.put_uint_lenenc(std::u16::MAX as u64); assert_eq!(&buf[..], b"\xFC\xFF\xFF"); } #[test] fn test_encodes_int_lenenc_u24() { let mut buf = Vec::with_capacity(1024); buf.put_uint_lenenc(0xFF_FF_FF_u64); assert_eq!(&buf[..], b"\xFD\xFF\xFF\xFF"); } #[test] fn test_encodes_int_lenenc_u64() { let mut buf = Vec::with_capacity(1024); buf.put_uint_lenenc(std::u64::MAX); assert_eq!(&buf[..], b"\xFE\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF"); } #[test] fn test_encodes_int_lenenc_fb() { let mut buf = Vec::with_capacity(1024); buf.put_uint_lenenc(0xFB_u64); assert_eq!(&buf[..], b"\xFC\xFB\x00"); } #[test] fn test_encodes_int_lenenc_fc() { let mut buf = Vec::with_capacity(1024); buf.put_uint_lenenc(0xFC_u64); assert_eq!(&buf[..], b"\xFC\xFC\x00"); } #[test] fn test_encodes_int_lenenc_fd() { let mut buf = Vec::with_capacity(1024); buf.put_uint_lenenc(0xFD_u64); assert_eq!(&buf[..], b"\xFC\xFD\x00"); } #[test] fn test_encodes_int_lenenc_fe() { let mut buf = Vec::with_capacity(1024); buf.put_uint_lenenc(0xFE_u64); assert_eq!(&buf[..], b"\xFC\xFE\x00"); } #[test] fn test_encodes_int_lenenc_ff() { let mut buf = Vec::with_capacity(1024); buf.put_uint_lenenc(0xFF_u64); assert_eq!(&buf[..], b"\xFC\xFF\x00"); } #[test] fn test_encodes_string_lenenc() { let mut buf = Vec::with_capacity(1024); buf.put_str_lenenc("random_string"); assert_eq!(&buf[..], b"\x0Drandom_string"); } #[test] fn test_encodes_byte_lenenc() { let mut buf = Vec::with_capacity(1024); buf.put_bytes_lenenc(b"random_string"); assert_eq!(&buf[..], b"\x0Drandom_string"); } ================================================ FILE: warpgate-database-protocols/src/mysql/io/mod.rs ================================================ mod buf; mod buf_mut; pub use buf::MySqlBufExt; pub use buf_mut::MySqlBufMutExt; ================================================ FILE: warpgate-database-protocols/src/mysql/mod.rs ================================================ //! **MySQL** database driver. pub mod collation; pub mod io; pub mod protocol; ================================================ FILE: warpgate-database-protocols/src/mysql/protocol/auth.rs ================================================ use std::str::FromStr; use crate::err_protocol; use crate::error::Error; #[derive(Debug, Copy, Clone, PartialEq, Eq)] #[allow(clippy::enum_variant_names)] pub enum AuthPlugin { MySqlClearPassword, MySqlNativePassword, CachingSha2Password, Sha256Password, } impl AuthPlugin { pub(crate) fn name(self) -> &'static str { match self { AuthPlugin::MySqlClearPassword => "mysql_clear_password", AuthPlugin::MySqlNativePassword => "mysql_native_password", AuthPlugin::CachingSha2Password => "caching_sha2_password", AuthPlugin::Sha256Password => "sha256_password", } } } impl FromStr for AuthPlugin { type Err = Error; fn from_str(s: &str) -> Result { match s { "mysql_clear_password" => Ok(AuthPlugin::MySqlClearPassword), "mysql_native_password" => Ok(AuthPlugin::MySqlNativePassword), "caching_sha2_password" => Ok(AuthPlugin::CachingSha2Password), "sha256_password" => Ok(AuthPlugin::Sha256Password), _ => Err(err_protocol!("unknown authentication plugin: {}", s)), } } } ================================================ FILE: warpgate-database-protocols/src/mysql/protocol/capabilities.rs ================================================ // https://dev.mysql.com/doc/dev/mysql-server/8.0.12/group__group__cs__capabilities__flags.html // https://mariadb.com/kb/en/library/connection/#capabilities bitflags::bitflags! { #[derive(PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Clone, Copy)] pub struct Capabilities: u64 { // [MariaDB] MySQL compatibility const MYSQL = 1; // [*] Send found rows instead of affected rows in EOF_Packet. const FOUND_ROWS = 2; // Get all column flags. const LONG_FLAG = 4; // [*] Database (schema) name can be specified on connect in Handshake Response Packet. const CONNECT_WITH_DB = 8; // Don't allow database.table.column const NO_SCHEMA = 16; // [*] Compression protocol supported const COMPRESS = 32; // Special handling of ODBC behavior. const ODBC = 64; // Can use LOAD DATA LOCAL const LOCAL_FILES = 128; // [*] Ignore spaces before '(' const IGNORE_SPACE = 256; // [*] New 4.1+ protocol const PROTOCOL_41 = 512; // This is an interactive client const INTERACTIVE = 1024; // Use SSL encryption for this session const SSL = 2048; // Client knows about transactions const TRANSACTIONS = 8192; // 4.1+ authentication const SECURE_CONNECTION = (1 << 15); // Enable/disable multi-statement support for COM_QUERY *and* COM_STMT_PREPARE const MULTI_STATEMENTS = (1 << 16); // Enable/disable multi-results for COM_QUERY const MULTI_RESULTS = (1 << 17); // Enable/disable multi-results for COM_STMT_PREPARE const PS_MULTI_RESULTS = (1 << 18); // Client supports plugin authentication const PLUGIN_AUTH = (1 << 19); // Client supports connection attributes const CONNECT_ATTRS = (1 << 20); // Enable authentication response packet to be larger than 255 bytes. const PLUGIN_AUTH_LENENC_DATA = (1 << 21); // Don't close the connection for a user account with expired password. const CAN_HANDLE_EXPIRED_PASSWORDS = (1 << 22); // Capable of handling server state change information. const SESSION_TRACK = (1 << 23); // Client no longer needs EOF_Packet and will use OK_Packet instead. const DEPRECATE_EOF = (1 << 24); // Support ZSTD protocol compression const ZSTD_COMPRESSION_ALGORITHM = (1 << 26); // Verify server certificate const SSL_VERIFY_SERVER_CERT = (1 << 30); // The client can handle optional metadata information in the resultset const OPTIONAL_RESULTSET_METADATA = (1 << 25); // Don't reset the options after an unsuccessful connect const REMEMBER_OPTIONS = (1 << 31); } } ================================================ FILE: warpgate-database-protocols/src/mysql/protocol/connect/auth_switch.rs ================================================ use bytes::{Buf, BufMut, Bytes}; use crate::err_protocol; use crate::error::Error; use crate::io::{BufExt, BufMutExt, Decode, Encode}; use crate::mysql::protocol::auth::AuthPlugin; use crate::mysql::protocol::Capabilities; // https://dev.mysql.com/doc/dev/mysql-server/8.0.12/page_protocol_connection_phase_packets_protocol_auth_switch_request.html #[derive(Debug)] pub struct AuthSwitchRequest { pub plugin: AuthPlugin, pub data: Bytes, } impl Decode<'_> for AuthSwitchRequest { fn decode_with(mut buf: Bytes, _: ()) -> Result { let header = buf.get_u8(); if header != 0xfe { return Err(err_protocol!( "expected 0xfe (AUTH_SWITCH) but found 0x{:x}", header )); } let plugin = buf.get_str_nul()?.parse()?; // See: https://github.com/mysql/mysql-server/blob/ea7d2e2d16ac03afdd9cb72a972a95981107bf51/sql/auth/sha2_password.cc#L942 if buf.len() != 21 { return Err(err_protocol!( "expected 21 bytes but found {} bytes", buf.len() )); } let data = buf.get_bytes(20); buf.advance(1); // NUL-terminator Ok(Self { plugin, data }) } } impl Encode<'_, ()> for AuthSwitchRequest { fn encode_with(&self, buf: &mut Vec, _: ()) { buf.put_u8(0xfe); buf.put_str_nul(self.plugin.name()); buf.extend(&self.data); } } #[derive(Debug)] pub struct AuthSwitchResponse(pub Vec); impl Encode<'_, Capabilities> for AuthSwitchResponse { fn encode_with(&self, buf: &mut Vec, _: Capabilities) { buf.extend_from_slice(&self.0); } } ================================================ FILE: warpgate-database-protocols/src/mysql/protocol/connect/handshake.rs ================================================ use bytes::buf::Chain; use bytes::{Buf, BufMut, Bytes}; use crate::error::Error; use crate::io::{BufExt, BufMutExt, Decode, Encode}; use crate::mysql::protocol::auth::AuthPlugin; use crate::mysql::protocol::response::Status; use crate::mysql::protocol::Capabilities; // https://dev.mysql.com/doc/internals/en/connection-phase-packets.html#packet-Protocol::Handshake // https://mariadb.com/kb/en/connection/#initial-handshake-packet #[derive(Debug)] pub struct Handshake { #[allow(unused)] pub protocol_version: u8, pub server_version: String, #[allow(unused)] pub connection_id: u32, pub server_capabilities: Capabilities, #[allow(unused)] pub server_default_collation: u8, #[allow(unused)] pub status: Status, pub auth_plugin: Option, pub auth_plugin_data: Chain, } impl Decode<'_> for Handshake { fn decode_with(mut buf: Bytes, _: ()) -> Result { let protocol_version = buf.get_u8(); // int<1> let server_version = buf.get_str_nul()?; // string let connection_id = buf.get_u32_le(); // int<4> let auth_plugin_data_1 = buf.get_bytes(8); // string<8> buf.advance(1); // reserved: string<1> let capabilities_1 = buf.get_u16_le(); // int<2> let mut capabilities = Capabilities::from_bits_truncate(capabilities_1.into()); let collation = buf.get_u8(); // int<1> let status = Status::from_bits_truncate(buf.get_u16_le()); let capabilities_2 = buf.get_u16_le(); // int<2> capabilities |= Capabilities::from_bits_truncate(((capabilities_2 as u32) << 16).into()); let auth_plugin_data_len = if capabilities.contains(Capabilities::PLUGIN_AUTH) { buf.get_u8() } else { buf.advance(1); // int<1> 0 }; buf.advance(6); // reserved: string<6> if capabilities.contains(Capabilities::MYSQL) { buf.advance(4); // reserved: string<4> } else { let capabilities_3 = buf.get_u32_le(); // int<4> capabilities |= Capabilities::from_bits_truncate((capabilities_3 as u64) << 32); } let auth_plugin_data_2 = if capabilities.contains(Capabilities::SECURE_CONNECTION) { let len = ((auth_plugin_data_len as isize) - 9).max(12) as usize; let v = buf.get_bytes(len); buf.advance(1); // NUL-terminator v } else { Bytes::new() }; let auth_plugin = if capabilities.contains(Capabilities::PLUGIN_AUTH) { Some(buf.get_str_nul()?.parse()?) } else { None }; Ok(Self { protocol_version, server_version, connection_id, server_default_collation: collation, status, server_capabilities: capabilities, auth_plugin, auth_plugin_data: auth_plugin_data_1.chain(auth_plugin_data_2), }) } } impl Encode<'_, ()> for Handshake { fn encode_with(&self, buf: &mut Vec, _: ()) { buf.put_u8(self.protocol_version); buf.put_str_nul(&self.server_version); buf.put_u32_le(self.connection_id); buf.put_slice(self.auth_plugin_data.first_ref()); buf.put_u8(0x00); buf.put_u16_le((self.server_capabilities.bits() & 0x0000_FFFF) as u16); buf.put_u8(self.server_default_collation); buf.put_u16_le(self.status.bits()); buf.put_u16_le(((self.server_capabilities.bits() & 0xFFFF_0000) >> 16) as u16); if self.server_capabilities.contains(Capabilities::PLUGIN_AUTH) { buf.put_u8((self.auth_plugin_data.last_ref().len() + 8 + 1) as u8); } else { buf.put_u8(0); } buf.put_slice(&[0_u8; 10][..]); if self .server_capabilities .contains(Capabilities::SECURE_CONNECTION) { buf.put_slice(self.auth_plugin_data.last_ref()); buf.put_u8(0); } if self.server_capabilities.contains(Capabilities::PLUGIN_AUTH) { if let Some(auth_plugin) = self.auth_plugin { buf.put_str_nul(auth_plugin.name()); } } } } #[test] #[allow(clippy::unwrap_used)] fn test_decode_handshake_mysql_8_0_18() { const HANDSHAKE_MYSQL_8_0_18: &[u8] = b"\n8.0.18\x00\x19\x00\x00\x00\x114aB0c\x06g\x00\xff\xff\xff\x02\x00\xff\xc7\x15\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00tL\x03s\x0f[4\rl4. \x00caching_sha2_password\x00"; let mut p = Handshake::decode(HANDSHAKE_MYSQL_8_0_18.into()).unwrap(); assert_eq!(p.protocol_version, 10); p.server_capabilities.toggle( Capabilities::MYSQL | Capabilities::FOUND_ROWS | Capabilities::LONG_FLAG | Capabilities::CONNECT_WITH_DB | Capabilities::NO_SCHEMA | Capabilities::COMPRESS | Capabilities::ODBC | Capabilities::LOCAL_FILES | Capabilities::IGNORE_SPACE | Capabilities::PROTOCOL_41 | Capabilities::INTERACTIVE | Capabilities::SSL | Capabilities::TRANSACTIONS | Capabilities::SECURE_CONNECTION | Capabilities::MULTI_STATEMENTS | Capabilities::MULTI_RESULTS | Capabilities::PS_MULTI_RESULTS | Capabilities::PLUGIN_AUTH | Capabilities::CONNECT_ATTRS | Capabilities::PLUGIN_AUTH_LENENC_DATA | Capabilities::CAN_HANDLE_EXPIRED_PASSWORDS | Capabilities::SESSION_TRACK | Capabilities::DEPRECATE_EOF | Capabilities::ZSTD_COMPRESSION_ALGORITHM | Capabilities::SSL_VERIFY_SERVER_CERT | Capabilities::OPTIONAL_RESULTSET_METADATA | Capabilities::REMEMBER_OPTIONS, ); assert!(p.server_capabilities.is_empty()); assert_eq!(p.server_default_collation, 255); assert!(p.status.contains(Status::SERVER_STATUS_AUTOCOMMIT)); assert!(matches!( p.auth_plugin, Some(AuthPlugin::CachingSha2Password) )); assert_eq!( &*p.auth_plugin_data.into_iter().collect::>(), &[17, 52, 97, 66, 48, 99, 6, 103, 116, 76, 3, 115, 15, 91, 52, 13, 108, 52, 46, 32,] ); } #[test] #[allow(clippy::unwrap_used)] fn test_decode_handshake_mariadb_10_4_7() { const HANDSHAKE_MARIA_DB_10_4_7: &[u8] = b"\n5.5.5-10.4.7-MariaDB-1:10.4.7+maria~bionic\x00\x0b\x00\x00\x00t6L\\j\"dS\x00\xfe\xf7\x08\x02\x00\xff\x81\x15\x00\x00\x00\x00\x00\x00\x07\x00\x00\x00U14Oph9\">(), &[116, 54, 76, 92, 106, 34, 100, 83, 85, 49, 52, 79, 112, 104, 57, 34, 60, 72, 53, 110,] ); } ================================================ FILE: warpgate-database-protocols/src/mysql/protocol/connect/handshake_response.rs ================================================ use std::str::FromStr; use bytes::{Buf, Bytes}; use crate::error::Error; use crate::io::{BufExt, BufMutExt, Decode, Encode}; use crate::mysql::io::{MySqlBufExt, MySqlBufMutExt}; use crate::mysql::protocol::auth::AuthPlugin; use crate::mysql::protocol::connect::ssl_request::SslRequest; use crate::mysql::protocol::Capabilities; // https://dev.mysql.com/doc/internals/en/connection-phase-packets.html#packet-Protocol::HandshakeResponse // https://mariadb.com/kb/en/connection/#client-handshake-response #[derive(Debug)] pub struct HandshakeResponse { pub database: Option, /// Max size of a command packet that the client wants to send to the server pub max_packet_size: u32, /// Default collation for the connection pub collation: u8, /// Name of the SQL account which client wants to log in pub username: String, /// Authentication method used by the client pub auth_plugin: Option, /// Opaque authentication response pub auth_response: Option, } impl Encode<'_, Capabilities> for HandshakeResponse { fn encode_with(&self, buf: &mut Vec, mut capabilities: Capabilities) { if self.auth_plugin.is_none() { // ensure PLUGIN_AUTH is set *only* if we have a defined plugin capabilities.remove(Capabilities::PLUGIN_AUTH); } // NOTE: Half of this packet is identical to the SSL Request packet SslRequest { max_packet_size: self.max_packet_size, collation: self.collation, } .encode_with(buf, capabilities); buf.put_str_nul(&self.username); if capabilities.contains(Capabilities::PLUGIN_AUTH_LENENC_DATA) { if let Some(response) = &self.auth_response { buf.put_bytes_lenenc(response); } else { buf.put_bytes_lenenc(&[]); } } else if capabilities.contains(Capabilities::SECURE_CONNECTION) { if let Some(response) = &self.auth_response { buf.push(response.len() as u8); buf.extend(response); } else { buf.push(0); } } else { buf.push(0); } if capabilities.contains(Capabilities::CONNECT_WITH_DB) { if let Some(database) = &self.database { buf.put_str_nul(database); } else { buf.push(0); } } if capabilities.contains(Capabilities::PLUGIN_AUTH) { if let Some(plugin) = &self.auth_plugin { buf.put_str_nul(plugin.name()); } else { buf.push(0); } } } } impl Decode<'_, &mut Capabilities> for HandshakeResponse { fn decode_with(mut buf: Bytes, server_capabilities: &mut Capabilities) -> Result { let mut capabilities = buf.get_u32_le() as u64; let max_packet_size = buf.get_u32_le(); let collation = buf.get_u8(); buf.advance(19); let partial_cap = Capabilities::from_bits_truncate(capabilities); if partial_cap.contains(Capabilities::MYSQL) { // reserved: string<4> buf.advance(4); } else { capabilities += (buf.get_u32_le() as u64) << 32; } let partial_cap = Capabilities::from_bits_truncate(capabilities); if partial_cap.contains(Capabilities::SSL) && buf.is_empty() { return Ok(HandshakeResponse { collation, max_packet_size, username: "".to_string(), auth_response: None, auth_plugin: None, database: None, }); } let username = buf.get_str_nul()?; let auth_response = if partial_cap.contains(Capabilities::PLUGIN_AUTH_LENENC_DATA) { Some(buf.get_bytes_lenenc()) } else if partial_cap.contains(Capabilities::SECURE_CONNECTION) { let len = buf.get_u8(); Some(buf.get_bytes(len as usize)) } else { Some(buf.get_bytes_nul()?) }; let database = if partial_cap.contains(Capabilities::CONNECT_WITH_DB) { Some(buf.get_str_nul()?) } else { None }; let auth_plugin: Option = if partial_cap.contains(Capabilities::PLUGIN_AUTH) { Some(AuthPlugin::from_str(&buf.get_str_nul()?)?) } else { None }; *server_capabilities &= Capabilities::from_bits_truncate(capabilities); Ok(HandshakeResponse { collation, max_packet_size, username, auth_response, auth_plugin, database, }) } } ================================================ FILE: warpgate-database-protocols/src/mysql/protocol/connect/mod.rs ================================================ //! Connection Phase //! //! mod auth_switch; mod handshake; mod handshake_response; mod ssl_request; pub use auth_switch::{AuthSwitchRequest, AuthSwitchResponse}; pub use handshake::Handshake; pub use handshake_response::HandshakeResponse; pub use ssl_request::SslRequest; ================================================ FILE: warpgate-database-protocols/src/mysql/protocol/connect/ssl_request.rs ================================================ use crate::io::Encode; use crate::mysql::protocol::Capabilities; // https://dev.mysql.com/doc/dev/mysql-server/8.0.12/page_protocol_connection_phase_packets_protocol_handshake_response.html // https://dev.mysql.com/doc/internals/en/connection-phase-packets.html#packet-Protocol::SSLRequest #[derive(Debug)] pub struct SslRequest { pub max_packet_size: u32, pub collation: u8, } impl Encode<'_, Capabilities> for SslRequest { fn encode_with(&self, buf: &mut Vec, capabilities: Capabilities) { buf.extend((capabilities.bits() as u32).to_le_bytes()); buf.extend(self.max_packet_size.to_le_bytes()); buf.push(self.collation); // reserved: string<19> buf.extend([0_u8; 19]); if capabilities.contains(Capabilities::MYSQL) { // reserved: string<4> buf.extend([0_u8; 4]); } else { // extended client capabilities (MariaDB-specified): int<4> buf.extend(((capabilities.bits() >> 32) as u32).to_le_bytes()); } } } ================================================ FILE: warpgate-database-protocols/src/mysql/protocol/mod.rs ================================================ pub mod auth; pub mod capabilities; pub mod connect; pub mod packet; pub mod response; pub mod row; pub mod text; pub use capabilities::Capabilities; pub use packet::Packet; pub use row::Row; ================================================ FILE: warpgate-database-protocols/src/mysql/protocol/packet.rs ================================================ use std::ops::{Deref, DerefMut}; use bytes::Bytes; use crate::error::Error; use crate::io::{Decode, Encode}; use crate::mysql::protocol::response::{EofPacket, OkPacket}; use crate::mysql::protocol::Capabilities; #[derive(Debug)] pub struct Packet(pub T); impl<'en, 'stream, T> Encode<'stream, (Capabilities, &'stream mut u8)> for Packet where T: Encode<'en, Capabilities>, { fn encode_with( &self, buf: &mut Vec, (capabilities, sequence_id): (Capabilities, &'stream mut u8), ) { // reserve space to write the prefixed length let offset = buf.len(); buf.extend([0_u8; 4]); // encode the payload self.0.encode_with(buf, capabilities); // determine the length of the encoded payload // and write to our reserved space let len = buf.len() - offset - 4; let header = &mut buf[offset..]; // FIXME: Support larger packets assert!(len < 0xFF_FF_FF); header[..4].copy_from_slice(&(len as u32).to_le_bytes()); header[3] = *sequence_id; *sequence_id = sequence_id.wrapping_add(1); } } impl Packet { pub(crate) fn decode<'de, T>(self) -> Result where T: Decode<'de, ()>, { self.decode_with(()) } pub(crate) fn decode_with<'de, T, C>(self, context: C) -> Result where T: Decode<'de, C>, { T::decode_with(self.0, context) } pub(crate) fn ok(self) -> Result { self.decode() } pub(crate) fn eof(self, capabilities: Capabilities) -> Result { if capabilities.contains(Capabilities::DEPRECATE_EOF) { let ok = self.ok()?; Ok(EofPacket { warnings: ok.warnings, status: ok.status, }) } else { self.decode_with(capabilities) } } } impl Deref for Packet { type Target = Bytes; fn deref(&self) -> &Bytes { &self.0 } } impl DerefMut for Packet { fn deref_mut(&mut self) -> &mut Bytes { &mut self.0 } } ================================================ FILE: warpgate-database-protocols/src/mysql/protocol/response/eof.rs ================================================ use bytes::{Buf, Bytes}; use crate::err_protocol; use crate::error::Error; use crate::io::Decode; use crate::mysql::protocol::response::Status; use crate::mysql::protocol::Capabilities; /// Marks the end of a result set, returning status and warnings. /// /// # Note /// /// The EOF packet is deprecated as of MySQL 5.7.5. SQLx only uses this packet for MySQL /// prior MySQL versions. #[derive(Debug)] pub struct EofPacket { pub warnings: u16, pub status: Status, } impl Decode<'_, Capabilities> for EofPacket { fn decode_with(mut buf: Bytes, _: Capabilities) -> Result { let header = buf.get_u8(); if header != 0xfe { return Err(err_protocol!( "expected 0xfe (EOF_Packet) but found 0x{:x}", header )); } let warnings = buf.get_u16_le(); let status = Status::from_bits_truncate(buf.get_u16_le()); Ok(Self { status, warnings }) } } ================================================ FILE: warpgate-database-protocols/src/mysql/protocol/response/err.rs ================================================ use bytes::{Buf, BufMut, Bytes}; use crate::err_protocol; use crate::error::Error; use crate::io::{BufExt, Decode, Encode}; use crate::mysql::protocol::Capabilities; // https://dev.mysql.com/doc/dev/mysql-server/8.0.12/page_protocol_basic_err_packet.html // https://mariadb.com/kb/en/err_packet/ /// Indicates that an error occurred. #[derive(Debug)] pub struct ErrPacket { pub error_code: u16, pub sql_state: Option, pub error_message: String, } impl Decode<'_, Capabilities> for ErrPacket { fn decode_with(mut buf: Bytes, capabilities: Capabilities) -> Result { let header = buf.get_u8(); if header != 0xff { return Err(err_protocol!( "expected 0xff (ERR_Packet) but found 0x{:x}", header )); } let error_code = buf.get_u16_le(); let mut sql_state = None; if capabilities.contains(Capabilities::PROTOCOL_41) { // If the next byte is '#' then we have a SQL STATE if buf.first() == Some(&0x23) { buf.advance(1); sql_state = Some(buf.get_str(5)?); } } let error_message = buf.get_str(buf.len())?; Ok(Self { error_code, sql_state, error_message, }) } } impl Encode<'_, ()> for ErrPacket { fn encode_with(&self, buf: &mut Vec, _: ()) { buf.put_u8(0xff); buf.put_u16_le(self.error_code); buf.extend_from_slice(self.error_message.as_bytes()) //TODO: sql_state } } #[test] #[allow(clippy::unwrap_used)] fn test_decode_err_packet_out_of_order() { const ERR_PACKETS_OUT_OF_ORDER: &[u8] = b"\xff\x84\x04Got packets out of order"; let p = ErrPacket::decode_with(ERR_PACKETS_OUT_OF_ORDER.into(), Capabilities::PROTOCOL_41).unwrap(); assert_eq!(&p.error_message, "Got packets out of order"); assert_eq!(p.error_code, 1156); assert_eq!(p.sql_state, None); } #[test] #[allow(clippy::unwrap_used)] fn test_decode_err_packet_unknown_database() { const ERR_HANDSHAKE_UNKNOWN_DB: &[u8] = b"\xff\x19\x04#42000Unknown database \'unknown\'"; let p = ErrPacket::decode_with(ERR_HANDSHAKE_UNKNOWN_DB.into(), Capabilities::PROTOCOL_41).unwrap(); assert_eq!(p.error_code, 1049); assert_eq!(p.sql_state.as_deref(), Some("42000")); assert_eq!(&p.error_message, "Unknown database \'unknown\'"); } ================================================ FILE: warpgate-database-protocols/src/mysql/protocol/response/mod.rs ================================================ //! Generic Response Packets //! //! //! mod eof; mod err; mod ok; mod status; pub use eof::EofPacket; pub use err::ErrPacket; pub use ok::OkPacket; pub use status::Status; ================================================ FILE: warpgate-database-protocols/src/mysql/protocol/response/ok.rs ================================================ use bytes::{Buf, BufMut, Bytes}; use crate::err_protocol; use crate::error::Error; use crate::io::{Decode, Encode}; use crate::mysql::io::{MySqlBufExt, MySqlBufMutExt}; use crate::mysql::protocol::response::Status; /// Indicates successful completion of a previous command sent by the client. #[derive(Debug)] pub struct OkPacket { pub affected_rows: u64, pub last_insert_id: u64, pub status: Status, pub warnings: u16, } impl Decode<'_> for OkPacket { fn decode_with(mut buf: Bytes, _: ()) -> Result { let header = buf.get_u8(); if header != 0 && header != 0xfe { return Err(err_protocol!( "expected 0x00 or 0xfe (OK_Packet) but found 0x{:02x}", header )); } let affected_rows = buf.get_uint_lenenc(); let last_insert_id = buf.get_uint_lenenc(); let status = Status::from_bits_truncate(buf.get_u16_le()); let warnings = buf.get_u16_le(); Ok(Self { affected_rows, last_insert_id, status, warnings, }) } } impl Encode<'_, ()> for OkPacket { fn encode_with(&self, buf: &mut Vec, _: ()) { buf.put_u8(0); buf.put_uint_lenenc(self.affected_rows); buf.put_uint_lenenc(self.last_insert_id); buf.put_u16_le(self.status.bits()); buf.put_u16_le(self.warnings); } } #[test] #[allow(clippy::unwrap_used)] fn test_decode_ok_packet() { const DATA: &[u8] = b"\x00\x00\x00\x02@\x00\x00"; let p = OkPacket::decode(DATA.into()).unwrap(); assert_eq!(p.affected_rows, 0); assert_eq!(p.last_insert_id, 0); assert_eq!(p.warnings, 0); assert!(p.status.contains(Status::SERVER_STATUS_AUTOCOMMIT)); assert!(p.status.contains(Status::SERVER_SESSION_STATE_CHANGED)); } ================================================ FILE: warpgate-database-protocols/src/mysql/protocol/response/status.rs ================================================ // https://dev.mysql.com/doc/dev/mysql-server/8.0.12/mysql__com_8h.html#a1d854e841086925be1883e4d7b4e8cad // https://mariadb.com/kb/en/library/mariadb-connectorc-types-and-definitions/#server-status bitflags::bitflags! { #[derive(PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Clone, Copy)] pub struct Status: u16 { // Is raised when a multi-statement transaction has been started, either explicitly, // by means of BEGIN or COMMIT AND CHAIN, or implicitly, by the first // transactional statement, when autocommit=off. const SERVER_STATUS_IN_TRANS = 1; // Autocommit mode is set const SERVER_STATUS_AUTOCOMMIT = 2; // Multi query - next query exists. const SERVER_MORE_RESULTS_EXISTS = 8; const SERVER_QUERY_NO_GOOD_INDEX_USED = 16; const SERVER_QUERY_NO_INDEX_USED = 32; // When using COM_STMT_FETCH, indicate that current cursor still has result const SERVER_STATUS_CURSOR_EXISTS = 64; // When using COM_STMT_FETCH, indicate that current cursor has finished to send results const SERVER_STATUS_LAST_ROW_SENT = 128; // Database has been dropped const SERVER_STATUS_DB_DROPPED = (1 << 8); // Current escape mode is "no backslash escape" const SERVER_STATUS_NO_BACKSLASH_ESCAPES = (1 << 9); // A DDL change did have an impact on an existing PREPARE (an automatic // re-prepare has been executed) const SERVER_STATUS_METADATA_CHANGED = (1 << 10); // Last statement took more than the time value specified // in server variable long_query_time. const SERVER_QUERY_WAS_SLOW = (1 << 11); // This result-set contain stored procedure output parameter. const SERVER_PS_OUT_PARAMS = (1 << 12); // Current transaction is a read-only transaction. const SERVER_STATUS_IN_TRANS_READONLY = (1 << 13); // This status flag, when on, implies that one of the state information has changed // on the server because of the execution of the last statement. const SERVER_SESSION_STATE_CHANGED = (1 << 14); } } ================================================ FILE: warpgate-database-protocols/src/mysql/protocol/row.rs ================================================ use std::ops::Range; use bytes::Bytes; #[derive(Debug)] pub struct Row { pub(crate) storage: Bytes, pub(crate) values: Vec>>, } impl Row { pub(crate) fn get(&self, index: usize) -> Option<&[u8]> { self.values[index] .as_ref() .map(|col| &self.storage[col.start..col.end]) } } ================================================ FILE: warpgate-database-protocols/src/mysql/protocol/text/column.rs ================================================ use std::str::from_utf8; use bitflags::bitflags; use bytes::{Buf, Bytes}; use crate::err_protocol; use crate::error::Error; use crate::io::Decode; use crate::mysql::io::MySqlBufExt; use crate::mysql::protocol::Capabilities; // https://dev.mysql.com/doc/dev/mysql-server/8.0.12/group__group__cs__column__definition__flags.html bitflags! { #[derive(PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Clone, Copy)] pub struct ColumnFlags: u16 { /// Field can't be `NULL`. const NOT_NULL = 1; /// Field is part of a primary key. const PRIMARY_KEY = 2; /// Field is part of a unique key. const UNIQUE_KEY = 4; /// Field is part of a multi-part unique or primary key. const MULTIPLE_KEY = 8; /// Field is a blob. const BLOB = 16; /// Field is unsigned. const UNSIGNED = 32; /// Field is zero filled. const ZEROFILL = 64; /// Field is binary. const BINARY = 128; /// Field is an enumeration. const ENUM = 256; /// Field is an auto-incement field. const AUTO_INCREMENT = 512; /// Field is a timestamp. const TIMESTAMP = 1024; /// Field is a set. const SET = 2048; /// Field does not have a default value. const NO_DEFAULT_VALUE = 4096; /// Field is set to NOW on UPDATE. const ON_UPDATE_NOW = 8192; /// Field is a number. const NUM = 32768; } } // https://dev.mysql.com/doc/internals/en/com-query-response.html#column-type #[derive(Debug, Copy, Clone, PartialEq, Eq)] #[repr(u8)] pub enum ColumnType { Decimal = 0x00, Tiny = 0x01, Short = 0x02, Long = 0x03, Float = 0x04, Double = 0x05, Null = 0x06, Timestamp = 0x07, LongLong = 0x08, Int24 = 0x09, Date = 0x0a, Time = 0x0b, Datetime = 0x0c, Year = 0x0d, VarChar = 0x0f, Bit = 0x10, Json = 0xf5, NewDecimal = 0xf6, Enum = 0xf7, Set = 0xf8, TinyBlob = 0xf9, MediumBlob = 0xfa, LongBlob = 0xfb, Blob = 0xfc, VarString = 0xfd, String = 0xfe, Geometry = 0xff, } // https://dev.mysql.com/doc/dev/mysql-server/8.0.12/page_protocol_com_query_response_text_resultset_column_definition.html // https://mariadb.com/kb/en/resultset/#column-definition-packet // https://dev.mysql.com/doc/internals/en/com-query-response.html#packet-Protocol::ColumnDefinition41 #[derive(Debug)] pub struct ColumnDefinition { #[allow(unused)] catalog: Bytes, #[allow(unused)] schema: Bytes, #[allow(unused)] table_alias: Bytes, #[allow(unused)] table: Bytes, alias: Bytes, name: Bytes, pub(crate) char_set: u16, pub(crate) max_size: u32, pub(crate) r#type: ColumnType, pub(crate) flags: ColumnFlags, #[allow(unused)] decimals: u8, } impl ColumnDefinition { // NOTE: strings in-protocol are transmitted according to the client character set // as this is UTF-8, all these strings should be UTF-8 pub(crate) fn name(&self) -> Result<&str, Error> { from_utf8(&self.name).map_err(Error::protocol) } pub(crate) fn alias(&self) -> Result<&str, Error> { from_utf8(&self.alias).map_err(Error::protocol) } } impl Decode<'_, Capabilities> for ColumnDefinition { fn decode_with(mut buf: Bytes, _: Capabilities) -> Result { let catalog = buf.get_bytes_lenenc(); let schema = buf.get_bytes_lenenc(); let table_alias = buf.get_bytes_lenenc(); let table = buf.get_bytes_lenenc(); let alias = buf.get_bytes_lenenc(); let name = buf.get_bytes_lenenc(); let _next_len = buf.get_uint_lenenc(); // always 0x0c let char_set = buf.get_u16_le(); let max_size = buf.get_u32_le(); let type_id = buf.get_u8(); let flags = buf.get_u16_le(); let decimals = buf.get_u8(); Ok(Self { catalog, schema, table_alias, table, alias, name, char_set, max_size, r#type: ColumnType::try_from_u16(type_id)?, flags: ColumnFlags::from_bits_truncate(flags), decimals, }) } } impl ColumnType { pub(crate) fn name( self, char_set: u16, flags: ColumnFlags, max_size: Option, ) -> &'static str { let is_binary = char_set == 63; let is_unsigned = flags.contains(ColumnFlags::UNSIGNED); let is_enum = flags.contains(ColumnFlags::ENUM); match self { ColumnType::Tiny if max_size == Some(1) => "BOOLEAN", ColumnType::Tiny if is_unsigned => "TINYINT UNSIGNED", ColumnType::Short if is_unsigned => "SMALLINT UNSIGNED", ColumnType::Long if is_unsigned => "INT UNSIGNED", ColumnType::Int24 if is_unsigned => "MEDIUMINT UNSIGNED", ColumnType::LongLong if is_unsigned => "BIGINT UNSIGNED", ColumnType::Tiny => "TINYINT", ColumnType::Short => "SMALLINT", ColumnType::Long => "INT", ColumnType::Int24 => "MEDIUMINT", ColumnType::LongLong => "BIGINT", ColumnType::Float => "FLOAT", ColumnType::Double => "DOUBLE", ColumnType::Null => "NULL", ColumnType::Timestamp => "TIMESTAMP", ColumnType::Date => "DATE", ColumnType::Time => "TIME", ColumnType::Datetime => "DATETIME", ColumnType::Year => "YEAR", ColumnType::Bit => "BIT", ColumnType::Enum => "ENUM", ColumnType::Set => "SET", ColumnType::Decimal | ColumnType::NewDecimal => "DECIMAL", ColumnType::Geometry => "GEOMETRY", ColumnType::Json => "JSON", ColumnType::String if is_binary => "BINARY", ColumnType::String if is_enum => "ENUM", ColumnType::VarChar | ColumnType::VarString if is_binary => "VARBINARY", ColumnType::String => "CHAR", ColumnType::VarChar | ColumnType::VarString => "VARCHAR", ColumnType::TinyBlob if is_binary => "TINYBLOB", ColumnType::TinyBlob => "TINYTEXT", ColumnType::Blob if is_binary => "BLOB", ColumnType::Blob => "TEXT", ColumnType::MediumBlob if is_binary => "MEDIUMBLOB", ColumnType::MediumBlob => "MEDIUMTEXT", ColumnType::LongBlob if is_binary => "LONGBLOB", ColumnType::LongBlob => "LONGTEXT", } } pub(crate) fn try_from_u16(id: u8) -> Result { Ok(match id { 0x00 => ColumnType::Decimal, 0x01 => ColumnType::Tiny, 0x02 => ColumnType::Short, 0x03 => ColumnType::Long, 0x04 => ColumnType::Float, 0x05 => ColumnType::Double, 0x06 => ColumnType::Null, 0x07 => ColumnType::Timestamp, 0x08 => ColumnType::LongLong, 0x09 => ColumnType::Int24, 0x0a => ColumnType::Date, 0x0b => ColumnType::Time, 0x0c => ColumnType::Datetime, 0x0d => ColumnType::Year, // [internal] 0x0e => ColumnType::NewDate, 0x0f => ColumnType::VarChar, 0x10 => ColumnType::Bit, // [internal] 0x11 => ColumnType::Timestamp2, // [internal] 0x12 => ColumnType::Datetime2, // [internal] 0x13 => ColumnType::Time2, 0xf5 => ColumnType::Json, 0xf6 => ColumnType::NewDecimal, 0xf7 => ColumnType::Enum, 0xf8 => ColumnType::Set, 0xf9 => ColumnType::TinyBlob, 0xfa => ColumnType::MediumBlob, 0xfb => ColumnType::LongBlob, 0xfc => ColumnType::Blob, 0xfd => ColumnType::VarString, 0xfe => ColumnType::String, 0xff => ColumnType::Geometry, _ => { return Err(err_protocol!("unknown column type 0x{:02x}", id)); } }) } } ================================================ FILE: warpgate-database-protocols/src/mysql/protocol/text/mod.rs ================================================ mod column; mod ping; mod query; mod quit; pub use column::{ColumnDefinition, ColumnFlags, ColumnType}; pub use ping::Ping; pub use query::Query; pub use quit::Quit; ================================================ FILE: warpgate-database-protocols/src/mysql/protocol/text/ping.rs ================================================ use crate::io::Encode; use crate::mysql::protocol::Capabilities; // https://dev.mysql.com/doc/internals/en/com-ping.html #[derive(Debug)] pub struct Ping; impl Encode<'_, Capabilities> for Ping { fn encode_with(&self, buf: &mut Vec, _: Capabilities) { buf.push(0x0e); // COM_PING } } ================================================ FILE: warpgate-database-protocols/src/mysql/protocol/text/query.rs ================================================ use bytes::{Buf, Bytes}; use crate::error::Error; use crate::io::{BufExt, Decode, Encode}; use crate::mysql::protocol::Capabilities; // https://dev.mysql.com/doc/internals/en/com-query.html #[derive(Debug)] pub struct Query(pub String); impl Encode<'_, ()> for Query { fn encode_with(&self, buf: &mut Vec, _: ()) { buf.push(0x03); // COM_QUERY buf.extend(self.0.as_bytes()) } } impl Encode<'_, Capabilities> for Query { fn encode_with(&self, buf: &mut Vec, _: Capabilities) { buf.push(0x03); // COM_QUERY buf.extend(self.0.as_bytes()) } } impl Decode<'_> for Query { fn decode_with(mut buf: Bytes, _: ()) -> Result { buf.advance(1); let q = buf.get_str(buf.len())?; Ok(Query(q)) } } ================================================ FILE: warpgate-database-protocols/src/mysql/protocol/text/quit.rs ================================================ use crate::io::Encode; use crate::mysql::protocol::Capabilities; // https://dev.mysql.com/doc/internals/en/com-quit.html #[derive(Debug)] pub struct Quit; impl Encode<'_, Capabilities> for Quit { fn encode_with(&self, buf: &mut Vec, _: Capabilities) { buf.push(0x01); // COM_QUIT } } ================================================ FILE: warpgate-db-entities/Cargo.toml ================================================ [package] edition = "2021" license = "Apache-2.0" name = "warpgate-db-entities" version = "0.22.0" [dependencies] bytes = { version = "1.4", default-features = false } chrono = { version = "0.4", default-features = false, features = ["serde"] } poem-openapi.workspace = true sqlx.workspace = true sea-orm = { workspace = true, features = [ "macros", "with-chrono", "with-uuid", "with-json", ], default-features = false } serde.workspace = true serde_json.workspace = true uuid.workspace = true warpgate-common = { version = "*", path = "../warpgate-common", default-features = false } secrecy = "0.10" warpgate-tls = { version = "*", path = "../warpgate-tls", default-features = false } warpgate-ldap = { version = "*", path = "../warpgate-ldap" } ================================================ FILE: warpgate-db-entities/src/AdminRole.rs ================================================ use poem_openapi::Object; use sea_orm::entity::prelude::*; use serde::Serialize; use uuid::Uuid; // Permissions on admin roles. All fields are simple bools. The naming here matches the // permission list defined in the admin UI and in the code elsewhere. #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)] #[sea_orm(table_name = "admin_roles")] #[oai(rename = "AdminRole")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub name: String, #[sea_orm(column_type = "Text")] pub description: String, // permissions pub targets_create: bool, pub targets_edit: bool, pub targets_delete: bool, pub users_create: bool, pub users_edit: bool, pub users_delete: bool, pub access_roles_create: bool, pub access_roles_edit: bool, pub access_roles_delete: bool, pub access_roles_assign: bool, pub sessions_view: bool, pub sessions_terminate: bool, pub recordings_view: bool, pub tickets_create: bool, pub tickets_delete: bool, pub config_edit: bool, pub admin_roles_manage: bool, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { #[sea_orm(has_many = "super::UserAdminRoleAssignment::Entity")] UserAdminRoleAssignment, } impl Related for Entity { fn to() -> RelationDef { super::UserAdminRoleAssignment::Relation::User.def() } fn via() -> Option { Some( super::UserAdminRoleAssignment::Relation::AdminRole .def() .rev(), ) } } impl ActiveModelBehavior for ActiveModel {} impl From for warpgate_common::AdminRole { fn from(model: Model) -> Self { Self { id: model.id, name: model.name, description: model.description, targets_create: model.targets_create, targets_edit: model.targets_edit, targets_delete: model.targets_delete, users_create: model.users_create, users_edit: model.users_edit, users_delete: model.users_delete, access_roles_create: model.access_roles_create, access_roles_edit: model.access_roles_edit, access_roles_delete: model.access_roles_delete, access_roles_assign: model.access_roles_assign, sessions_view: model.sessions_view, sessions_terminate: model.sessions_terminate, recordings_view: model.recordings_view, tickets_create: model.tickets_create, tickets_delete: model.tickets_delete, config_edit: model.config_edit, admin_roles_manage: model.admin_roles_manage, } } } ================================================ FILE: warpgate-db-entities/src/ApiToken.rs ================================================ use chrono::{DateTime, Utc}; use sea_orm::entity::prelude::*; use sea_orm::sea_query::ForeignKeyAction; use serde::Serialize; use uuid::Uuid; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)] #[sea_orm(table_name = "api_tokens")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub user_id: Uuid, pub label: String, pub secret: String, pub created: DateTime, pub expiry: DateTime, } #[derive(Copy, Clone, Debug, EnumIter)] pub enum Relation { User, } impl RelationTrait for Relation { fn def(&self) -> RelationDef { match self { Self::User => Entity::belongs_to(super::User::Entity) .from(Column::UserId) .to(super::User::Column::Id) .on_delete(ForeignKeyAction::Cascade) .into(), } } } impl Related for Entity { fn to() -> RelationDef { Relation::User.def() } } impl ActiveModelBehavior for ActiveModel {} ================================================ FILE: warpgate-db-entities/src/CertificateCredential.rs ================================================ use chrono::{DateTime, Utc}; use sea_orm::entity::prelude::*; use sea_orm::sea_query::ForeignKeyAction; use sea_orm::Set; use serde::Serialize; use uuid::Uuid; use warpgate_common::{UserAuthCredential, UserCertificateCredential}; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)] #[sea_orm(table_name = "credentials_certificate")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub user_id: Uuid, pub label: String, pub date_added: Option>, pub last_used: Option>, #[sea_orm(column_type = "Text")] pub certificate_pem: String, } #[derive(Copy, Clone, Debug, EnumIter)] pub enum Relation { User, } impl RelationTrait for Relation { fn def(&self) -> RelationDef { match self { Self::User => Entity::belongs_to(super::User::Entity) .from(Column::UserId) .to(super::User::Column::Id) .on_delete(ForeignKeyAction::Cascade) .into(), } } } impl Related for Entity { fn to() -> RelationDef { Relation::User.def() } } impl ActiveModelBehavior for ActiveModel {} impl From for UserCertificateCredential { fn from(credential: Model) -> Self { UserCertificateCredential { certificate_pem: credential.certificate_pem.into(), } } } impl From for UserAuthCredential { fn from(model: Model) -> Self { Self::Certificate(model.into()) } } impl From for ActiveModel { fn from(credential: UserCertificateCredential) -> Self { Self { certificate_pem: Set(credential.certificate_pem.expose_secret().clone()), ..Default::default() } } } ================================================ FILE: warpgate-db-entities/src/CertificateRevocation.rs ================================================ use chrono::{DateTime, Utc}; use sea_orm::entity::prelude::*; use serde::Serialize; use uuid::Uuid; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)] #[sea_orm(table_name = "certificate_revocations")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub serial_number_base64: String, pub date_added: DateTime, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} ================================================ FILE: warpgate-db-entities/src/KnownHost.rs ================================================ use poem_openapi::Object; use sea_orm::entity::prelude::*; use serde::Serialize; use uuid::Uuid; #[derive(Clone, Debug, PartialEq, Eq, 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: i32, pub key_type: String, pub key_base64: String, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} impl Model { pub fn key_openssh(&self) -> String { format!("{} {}", self.key_type, self.key_base64) } } ================================================ FILE: warpgate-db-entities/src/LdapServer.rs ================================================ use poem_openapi::Object; use sea_orm::entity::prelude::*; use serde::Serialize; use uuid::Uuid; use warpgate_tls::TlsMode; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)] #[sea_orm(table_name = "ldap_servers")] #[oai(rename = "LdapServer")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, #[sea_orm(unique)] pub name: String, pub host: String, pub port: i32, pub bind_dn: String, pub bind_password: String, pub user_filter: String, pub base_dns: serde_json::Value, pub tls_mode: String, pub tls_verify: bool, pub enabled: bool, pub auto_link_sso_users: bool, #[sea_orm(column_type = "Text")] pub description: String, pub username_attribute: String, #[sea_orm(column_type = "Text")] pub ssh_key_attribute: String, pub uuid_attribute: String, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} impl TryFrom<&Model> for warpgate_ldap::LdapConfig { type Error = serde_json::Error; fn try_from(server: &Model) -> Result { let base_dns: Vec = serde_json::from_value(server.base_dns.clone())?; Ok(Self { host: server.host.clone(), port: server.port as u16, bind_dn: server.bind_dn.clone(), bind_password: server.bind_password.clone(), tls_mode: TlsMode::from(server.tls_mode.as_str()), tls_verify: server.tls_verify, base_dns, user_filter: server.user_filter.clone(), username_attribute: server .username_attribute .as_str() .try_into() .unwrap_or(warpgate_ldap::LdapUsernameAttribute::Cn), ssh_key_attribute: server.ssh_key_attribute.clone(), uuid_attribute: if server.uuid_attribute.is_empty() { None } else { Some(server.uuid_attribute.clone()) }, }) } } impl TryFrom for warpgate_ldap::LdapConfig { type Error = serde_json::Error; fn try_from(server: Model) -> Result { Self::try_from(&server) } } ================================================ FILE: warpgate-db-entities/src/LogEntry.rs ================================================ use chrono::{DateTime, Utc}; use poem_openapi::Object; use sea_orm::entity::prelude::*; use sea_orm::query::JsonValue; use serde::Serialize; use uuid::Uuid; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)] #[sea_orm(table_name = "log")] #[oai(rename = "LogEntry")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub text: String, pub values: JsonValue, pub timestamp: DateTime, pub session_id: Uuid, pub username: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} ================================================ FILE: warpgate-db-entities/src/OtpCredential.rs ================================================ use sea_orm::entity::prelude::*; use sea_orm::sea_query::ForeignKeyAction; use sea_orm::Set; use serde::Serialize; use uuid::Uuid; use warpgate_common::{UserAuthCredential, UserTotpCredential}; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)] #[sea_orm(table_name = "credentials_otp")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub user_id: Uuid, pub secret_key: Vec, } #[derive(Copy, Clone, Debug, EnumIter)] pub enum Relation { User, } impl RelationTrait for Relation { fn def(&self) -> RelationDef { match self { Self::User => Entity::belongs_to(super::User::Entity) .from(Column::UserId) .to(super::User::Column::Id) .on_delete(ForeignKeyAction::Cascade) .into(), } } } impl Related for Entity { fn to() -> RelationDef { Relation::User.def() } } impl ActiveModelBehavior for ActiveModel {} impl From for UserTotpCredential { fn from(credential: Model) -> Self { UserTotpCredential { key: credential.secret_key.into(), } } } impl From for UserAuthCredential { fn from(model: Model) -> Self { Self::Totp(model.into()) } } impl From for ActiveModel { fn from(credential: UserTotpCredential) -> Self { Self { secret_key: Set(credential.key.expose_secret().clone()), ..Default::default() } } } ================================================ FILE: warpgate-db-entities/src/Parameters.rs ================================================ use sea_orm::entity::prelude::*; use sea_orm::Set; use uuid::Uuid; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "parameters")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub allow_own_credential_management: bool, pub rate_limit_bytes_per_second: Option, #[sea_orm(column_type = "Text")] pub ca_certificate_pem: String, #[sea_orm(column_type = "Text")] pub ca_private_key_pem: String, pub ssh_client_auth_publickey: bool, pub ssh_client_auth_password: bool, pub ssh_client_auth_keyboard_interactive: bool, pub minimize_password_login: bool, } impl ActiveModelBehavior for ActiveModel {} #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} impl Entity { pub async fn get(db: &DatabaseConnection) -> Result { match Self::find().one(db).await? { Some(model) => Ok(model), None => { ActiveModel { id: Set(Uuid::new_v4()), allow_own_credential_management: Set(true), rate_limit_bytes_per_second: Set(None), ca_certificate_pem: Set("".into()), ca_private_key_pem: Set("".into()), ssh_client_auth_publickey: Set(true), ssh_client_auth_password: Set(true), ssh_client_auth_keyboard_interactive: Set(true), minimize_password_login: Set(false), } .insert(db) .await } } } } ================================================ FILE: warpgate-db-entities/src/PasswordCredential.rs ================================================ use sea_orm::entity::prelude::*; use sea_orm::sea_query::ForeignKeyAction; use sea_orm::Set; use serde::Serialize; use uuid::Uuid; use warpgate_common::{UserAuthCredential, UserPasswordCredential}; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)] #[sea_orm(table_name = "credentials_password")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub user_id: Uuid, pub argon_hash: String, } #[derive(Copy, Clone, Debug, EnumIter)] pub enum Relation { User, } impl RelationTrait for Relation { fn def(&self) -> RelationDef { match self { Self::User => Entity::belongs_to(super::User::Entity) .from(Column::UserId) .to(super::User::Column::Id) .on_delete(ForeignKeyAction::Cascade) .into(), } } } impl Related for Entity { fn to() -> RelationDef { Relation::User.def() } } impl ActiveModelBehavior for ActiveModel {} pub struct StrictModel { pub id: Uuid, pub credential: UserPasswordCredential, } impl From for StrictModel { fn from(model: Model) -> Self { Self { id: model.id, credential: model.clone().into(), } } } impl From for UserPasswordCredential { fn from(credential: Model) -> Self { UserPasswordCredential { hash: credential.argon_hash.to_owned().into(), } } } impl From for UserAuthCredential { fn from(model: Model) -> Self { Self::Password(model.into()) } } impl From for ActiveModel { fn from(credential: UserPasswordCredential) -> Self { Self { argon_hash: Set(credential.hash.expose_secret().into()), ..Default::default() } } } ================================================ FILE: warpgate-db-entities/src/PublicKeyCredential.rs ================================================ use chrono::{DateTime, Utc}; use sea_orm::entity::prelude::*; use sea_orm::sea_query::ForeignKeyAction; use sea_orm::Set; use serde::Serialize; use uuid::Uuid; use warpgate_common::{UserAuthCredential, UserPublicKeyCredential}; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)] #[sea_orm(table_name = "credentials_public_key")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub user_id: Uuid, pub label: String, pub date_added: Option>, pub last_used: Option>, #[sea_orm(column_type = "Text")] pub openssh_public_key: String, } #[derive(Copy, Clone, Debug, EnumIter)] pub enum Relation { User, } impl RelationTrait for Relation { fn def(&self) -> RelationDef { match self { Self::User => Entity::belongs_to(super::User::Entity) .from(Column::UserId) .to(super::User::Column::Id) .on_delete(ForeignKeyAction::Cascade) .into(), } } } impl Related for Entity { fn to() -> RelationDef { Relation::User.def() } } impl ActiveModelBehavior for ActiveModel {} impl From for UserPublicKeyCredential { fn from(credential: Model) -> Self { UserPublicKeyCredential { key: credential.openssh_public_key.into(), } } } impl From for UserAuthCredential { fn from(model: Model) -> Self { Self::PublicKey(model.into()) } } impl From for ActiveModel { fn from(credential: UserPublicKeyCredential) -> Self { Self { openssh_public_key: Set(credential.key.expose_secret().clone()), ..Default::default() } } } ================================================ FILE: warpgate-db-entities/src/Recording.rs ================================================ 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, Eq, EnumIter, Enum, DeriveActiveEnum, Serialize)] #[sea_orm(rs_type = "String", db_type = "String(StringLen::N(16))")] pub enum RecordingKind { #[sea_orm(string_value = "terminal")] Terminal, #[sea_orm(string_value = "traffic")] Traffic, #[sea_orm(string_value = "kubernetes")] Kubernetes, } #[derive(Clone, Debug, PartialEq, Eq, 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, pub ended: Option>, pub session_id: Uuid, pub kind: RecordingKind, #[sea_orm(column_type = "Text")] pub metadata: String, } #[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 for Entity { fn to() -> RelationDef { Relation::Session.def() } } impl ActiveModelBehavior for ActiveModel {} ================================================ FILE: warpgate-db-entities/src/Role.rs ================================================ use poem_openapi::Object; use sea_orm::entity::prelude::*; use serde::Serialize; use uuid::Uuid; use warpgate_common::Role; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)] #[sea_orm(table_name = "roles")] #[oai(rename = "Role")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub name: String, #[sea_orm(column_type = "Text")] pub description: String, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} impl Related for Entity { fn to() -> RelationDef { super::TargetRoleAssignment::Relation::Target.def() } fn via() -> Option { Some(super::TargetRoleAssignment::Relation::Role.def().rev()) } } impl Related for Entity { fn to() -> RelationDef { super::UserRoleAssignment::Relation::User.def() } fn via() -> Option { Some(super::UserRoleAssignment::Relation::Role.def().rev()) } } impl ActiveModelBehavior for ActiveModel {} impl From for Role { fn from(model: Model) -> Self { Self { id: model.id, name: model.name, description: model.description, } } } ================================================ FILE: warpgate-db-entities/src/Session.rs ================================================ use chrono::{DateTime, Utc}; use sea_orm::entity::prelude::*; use uuid::Uuid; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "sessions")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub target_snapshot: Option, pub username: Option, pub remote_address: String, pub started: DateTime, pub ended: Option>, pub ticket_id: Option, pub protocol: String, } #[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 for Entity { fn to() -> RelationDef { Relation::Ticket.def() } } impl ActiveModelBehavior for ActiveModel {} ================================================ FILE: warpgate-db-entities/src/SsoCredential.rs ================================================ use sea_orm::entity::prelude::*; use sea_orm::sea_query::ForeignKeyAction; use sea_orm::Set; use serde::Serialize; use uuid::Uuid; use warpgate_common::{UserAuthCredential, UserSsoCredential}; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)] #[sea_orm(table_name = "credentials_sso")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub user_id: Uuid, pub provider: Option, pub email: String, } #[derive(Copy, Clone, Debug, EnumIter)] pub enum Relation { User, } impl RelationTrait for Relation { fn def(&self) -> RelationDef { match self { Self::User => Entity::belongs_to(super::User::Entity) .from(Column::UserId) .to(super::User::Column::Id) .on_delete(ForeignKeyAction::Cascade) .into(), } } } impl Related for Entity { fn to() -> RelationDef { Relation::User.def() } } impl ActiveModelBehavior for ActiveModel {} impl From for UserSsoCredential { fn from(credential: Model) -> Self { UserSsoCredential { provider: credential.provider, email: credential.email, } } } impl From for UserAuthCredential { fn from(model: Model) -> Self { Self::Sso(model.into()) } } impl From for ActiveModel { fn from(credential: UserSsoCredential) -> Self { Self { provider: Set(credential.provider), email: Set(credential.email), ..Default::default() } } } ================================================ FILE: warpgate-db-entities/src/Target.rs ================================================ use poem_openapi::{Enum, Object}; use sea_orm::entity::prelude::*; use serde::Serialize; use uuid::Uuid; use warpgate_common::{Target, TargetOptions}; #[derive(Debug, PartialEq, Eq, Serialize, Clone, Enum, EnumIter, DeriveActiveEnum)] #[sea_orm(rs_type = "String", db_type = "String(StringLen::N(16))")] pub enum TargetKind { #[sea_orm(string_value = "http")] Http, #[sea_orm(string_value = "kubernetes")] Kubernetes, #[sea_orm(string_value = "mysql")] MySql, #[sea_orm(string_value = "ssh")] Ssh, #[sea_orm(string_value = "postgres")] Postgres, } impl From<&TargetOptions> for TargetKind { fn from(options: &TargetOptions) -> Self { match options { TargetOptions::Http(_) => Self::Http, TargetOptions::Kubernetes(_) => Self::Kubernetes, TargetOptions::MySql(_) => Self::MySql, TargetOptions::Postgres(_) => Self::Postgres, TargetOptions::Ssh(_) => Self::Ssh, } } } #[derive(Debug, PartialEq, Eq, Serialize, Clone, Enum, EnumIter, DeriveActiveEnum)] #[sea_orm(rs_type = "String", db_type = "String(StringLen::N(16))")] pub enum SshAuthKind { #[sea_orm(string_value = "password")] Password, #[sea_orm(string_value = "publickey")] PublicKey, } #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)] #[sea_orm(table_name = "targets")] #[oai(rename = "Target")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub name: String, #[sea_orm(column_type = "Text")] pub description: String, pub kind: TargetKind, pub options: serde_json::Value, pub rate_limit_bytes_per_second: Option, pub group_id: Option, } impl Related for Entity { fn to() -> RelationDef { super::TargetRoleAssignment::Relation::Role.def() } fn via() -> Option { Some(super::TargetRoleAssignment::Relation::Target.def().rev()) } } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { #[sea_orm( belongs_to = "super::TargetGroup::Entity", from = "Column::GroupId", to = "super::TargetGroup::Column::Id" )] TargetGroup, } impl Related for Entity { fn to() -> RelationDef { Relation::TargetGroup.def() } } impl ActiveModelBehavior for ActiveModel {} impl TryFrom for Target { type Error = serde_json::Error; fn try_from(model: Model) -> Result { let options: TargetOptions = serde_json::from_value(model.options)?; Ok(Self { id: model.id, name: model.name, description: model.description, allow_roles: vec![], options, rate_limit_bytes_per_second: model.rate_limit_bytes_per_second.map(|v| v as u32), group_id: model.group_id, }) } } ================================================ FILE: warpgate-db-entities/src/TargetGroup.rs ================================================ use poem_openapi::{Enum, Object}; use sea_orm::entity::prelude::*; use serde::Serialize; use uuid::Uuid; #[derive(Debug, PartialEq, Eq, Serialize, Clone, Enum, EnumIter, DeriveActiveEnum)] #[sea_orm(rs_type = "String", db_type = "String(StringLen::N(16))")] pub enum BootstrapThemeColor { #[sea_orm(string_value = "primary")] Primary, #[sea_orm(string_value = "secondary")] Secondary, #[sea_orm(string_value = "success")] Success, #[sea_orm(string_value = "danger")] Danger, #[sea_orm(string_value = "warning")] Warning, #[sea_orm(string_value = "info")] Info, #[sea_orm(string_value = "light")] Light, #[sea_orm(string_value = "dark")] Dark, } #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)] #[sea_orm(table_name = "target_groups")] #[oai(rename = "TargetGroup")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub name: String, #[sea_orm(column_type = "Text")] pub description: String, pub color: Option, // Bootstrap theme color for UI display } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { #[sea_orm(has_many = "super::Target::Entity")] Target, } impl ActiveModelBehavior for ActiveModel {} ================================================ FILE: warpgate-db-entities/src/TargetRoleAssignment.rs ================================================ use poem_openapi::Object; use sea_orm::entity::prelude::*; use serde::Serialize; use uuid::Uuid; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)] #[sea_orm(table_name = "target_roles")] #[oai(rename = "TargetRoleAssignment")] pub struct Model { #[sea_orm(primary_key, auto_increment = true)] pub id: i32, pub target_id: Uuid, pub role_id: Uuid, } #[derive(Copy, Clone, Debug, EnumIter)] pub enum Relation { Target, Role, } impl RelationTrait for Relation { fn def(&self) -> RelationDef { match self { Self::Target => Entity::belongs_to(super::Target::Entity) .from(Column::TargetId) .to(super::Target::Column::Id) .into(), Self::Role => Entity::belongs_to(super::Role::Entity) .from(Column::RoleId) .to(super::Role::Column::Id) .into(), } } } impl ActiveModelBehavior for ActiveModel {} ================================================ FILE: warpgate-db-entities/src/Ticket.rs ================================================ use chrono::{DateTime, Utc}; use poem_openapi::Object; use sea_orm::entity::prelude::*; use serde::Serialize; use uuid::Uuid; #[derive(Clone, Debug, PartialEq, Eq, 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, #[sea_orm(column_type = "Text")] pub description: String, pub target: String, pub uses_left: Option, pub expiry: Option>, pub created: DateTime, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { #[sea_orm(has_many = "super::Session::Entity")] Sessions, } impl ActiveModelBehavior for ActiveModel {} ================================================ FILE: warpgate-db-entities/src/User.rs ================================================ use poem_openapi::Object; use sea_orm::entity::prelude::*; use sea_orm::Set; use serde::Serialize; use uuid::Uuid; use warpgate_common::{User, UserDetails, WarpgateError}; use crate::{ CertificateCredential, OtpCredential, PasswordCredential, PublicKeyCredential, Role, SsoCredential, }; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)] #[sea_orm(table_name = "users")] #[oai(rename = "User")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub username: String, pub credential_policy: serde_json::Value, #[sea_orm(column_type = "Text")] pub description: String, pub rate_limit_bytes_per_second: Option, pub ldap_server_id: Option, #[sea_orm(column_type = "Text", nullable)] pub ldap_object_uuid: Option, } impl Related for Entity { fn to() -> RelationDef { super::UserRoleAssignment::Relation::Role.def() } fn via() -> Option { Some(super::UserRoleAssignment::Relation::User.def().rev()) } } impl Related for Entity { fn to() -> RelationDef { super::UserAdminRoleAssignment::Relation::AdminRole.def() } fn via() -> Option { Some(super::UserAdminRoleAssignment::Relation::User.def().rev()) } } impl Related for Entity { fn to() -> RelationDef { Relation::OtpCredentials.def() } } impl Related for Entity { fn to() -> RelationDef { Relation::PasswordCredentials.def() } } impl Related for Entity { fn to() -> RelationDef { Relation::PublicKeyCredentials.def() } } impl Related for Entity { fn to() -> RelationDef { Relation::CertificateCredentials.def() } } impl Related for Entity { fn to() -> RelationDef { Relation::SsoCredentials.def() } } impl Related for Entity { fn to() -> RelationDef { Relation::ApiTokens.def() } } #[derive(Copy, Clone, Debug, EnumIter)] #[allow(clippy::enum_variant_names)] pub enum Relation { OtpCredentials, PasswordCredentials, PublicKeyCredentials, CertificateCredentials, SsoCredentials, ApiTokens, AdminRoles, } impl RelationTrait for Relation { fn def(&self) -> RelationDef { match self { Self::OtpCredentials => Entity::has_many(super::OtpCredential::Entity) .from(Column::Id) .to(super::OtpCredential::Column::UserId) .into(), Self::PasswordCredentials => Entity::has_many(super::PasswordCredential::Entity) .from(Column::Id) .to(super::PasswordCredential::Column::UserId) .into(), Self::PublicKeyCredentials => Entity::has_many(super::PublicKeyCredential::Entity) .from(Column::Id) .to(super::PublicKeyCredential::Column::UserId) .into(), Self::CertificateCredentials => Entity::has_many(super::CertificateCredential::Entity) .from(Column::Id) .to(super::CertificateCredential::Column::UserId) .into(), Self::SsoCredentials => Entity::has_many(super::SsoCredential::Entity) .from(Column::Id) .to(super::SsoCredential::Column::UserId) .into(), Self::ApiTokens => Entity::has_many(super::ApiToken::Entity) .from(Column::Id) .to(super::ApiToken::Column::UserId) .into(), Self::AdminRoles => Entity::has_many(super::UserAdminRoleAssignment::Entity) .from(Column::Id) .to(super::UserAdminRoleAssignment::Column::UserId) .into(), } } } impl ActiveModelBehavior for ActiveModel {} impl TryFrom for User { type Error = WarpgateError; fn try_from(model: Model) -> Result { Ok(User { id: model.id, username: model.username, credential_policy: serde_json::from_value(model.credential_policy)?, description: model.description, rate_limit_bytes_per_second: model.rate_limit_bytes_per_second, ldap_server_id: model.ldap_server_id, }) } } impl Model { pub async fn load_details(self, db: &DatabaseConnection) -> Result { let roles: Vec = self .find_related(Role::Entity) .all(db) .await? .into_iter() .map(Into::::into) .map(|x| x.name) .collect(); let mut credentials = vec![]; credentials.extend( self.find_related(OtpCredential::Entity) .all(db) .await? .into_iter() .map(|x| x.into()), ); credentials.extend( self.find_related(PasswordCredential::Entity) .all(db) .await? .into_iter() .map(|x| x.into()), ); credentials.extend( self.find_related(SsoCredential::Entity) .all(db) .await? .into_iter() .map(|x| x.into()), ); credentials.extend( self.find_related(PublicKeyCredential::Entity) .all(db) .await? .into_iter() .map(|x| x.into()), ); credentials.extend( self.find_related(CertificateCredential::Entity) .all(db) .await? .into_iter() .map(|x| x.into()), ); Ok(warpgate_common::UserDetails { inner: self.try_into()?, roles, credentials, }) } } impl TryFrom for ActiveModel { type Error = WarpgateError; fn try_from(user: User) -> Result { Ok(Self { id: Set(user.id), username: Set(user.username), credential_policy: Set(serde_json::to_value(&user.credential_policy)?), description: Set(user.description), rate_limit_bytes_per_second: Set(user.rate_limit_bytes_per_second), ldap_server_id: Set(None), ldap_object_uuid: Set(None), }) } } ================================================ FILE: warpgate-db-entities/src/UserAdminRoleAssignment.rs ================================================ use poem_openapi::Object; use sea_orm::entity::prelude::*; use serde::Serialize; use uuid::Uuid; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)] #[sea_orm(table_name = "user_admin_roles")] #[oai(rename = "UserAdminRoleAssignment")] pub struct Model { #[sea_orm(primary_key, auto_increment = true)] pub id: i32, pub user_id: Uuid, pub admin_role_id: Uuid, } #[derive(Copy, Clone, Debug, EnumIter)] pub enum Relation { User, AdminRole, } impl RelationTrait for Relation { fn def(&self) -> RelationDef { match self { Self::User => Entity::belongs_to(super::User::Entity) .from(Column::UserId) .to(super::User::Column::Id) .into(), Self::AdminRole => Entity::belongs_to(super::AdminRole::Entity) .from(Column::AdminRoleId) .to(super::AdminRole::Column::Id) .into(), } } } impl ActiveModelBehavior for ActiveModel {} impl Related for Entity { fn to() -> RelationDef { Relation::AdminRole.def() } } impl Related for Entity { fn to() -> RelationDef { Relation::User.def() } } ================================================ FILE: warpgate-db-entities/src/UserRoleAssignment.rs ================================================ use poem_openapi::Object; use sea_orm::entity::prelude::*; use serde::Serialize; use uuid::Uuid; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)] #[sea_orm(table_name = "user_roles")] #[oai(rename = "UserRoleAssignment")] pub struct Model { #[sea_orm(primary_key, auto_increment = true)] pub id: i32, pub user_id: Uuid, pub role_id: Uuid, } #[derive(Copy, Clone, Debug, EnumIter)] pub enum Relation { User, Role, } impl RelationTrait for Relation { fn def(&self) -> RelationDef { match self { Self::User => Entity::belongs_to(super::User::Entity) .from(Column::UserId) .to(super::User::Column::Id) .into(), Self::Role => Entity::belongs_to(super::Role::Entity) .from(Column::RoleId) .to(super::Role::Column::Id) .into(), } } } impl ActiveModelBehavior for ActiveModel {} ================================================ FILE: warpgate-db-entities/src/lib.rs ================================================ #![allow(non_snake_case)] pub mod AdminRole; pub mod ApiToken; pub mod CertificateCredential; pub mod CertificateRevocation; pub mod KnownHost; pub mod LdapServer; pub mod LogEntry; pub mod OtpCredential; pub mod Parameters; pub mod PasswordCredential; pub mod PublicKeyCredential; pub mod Recording; pub mod Role; pub mod Session; pub mod SsoCredential; pub mod Target; pub mod TargetGroup; pub mod TargetRoleAssignment; pub mod Ticket; pub mod User; pub mod UserAdminRoleAssignment; pub mod UserRoleAssignment; ================================================ FILE: warpgate-db-migrations/Cargo.toml ================================================ [package] edition = "2021" license = "Apache-2.0" name = "warpgate-db-migrations" publish = false version = "0.22.0" [lib] [dependencies] tokio.workspace = true chrono = { version = "0.4", default-features = false, features = ["serde"] } data-encoding.workspace = true regex.workspace = true sea-orm = { workspace = true, features = [ "with-chrono", "with-uuid", "with-json", ], default-features = false } sea-orm-migration.workspace = true russh.workspace = true tracing.workspace = true uuid.workspace = true serde_json.workspace = true serde.workspace = true warpgate-ca = { version = "*", path = "../warpgate-ca", default-features = false } [features] postgres = ["sea-orm/sqlx-postgres"] mysql = ["sea-orm/sqlx-mysql"] sqlite = ["sea-orm/sqlx-sqlite"] ================================================ FILE: warpgate-db-migrations/README.md ================================================ # 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 ``` ================================================ FILE: warpgate-db-migrations/src/lib.rs ================================================ use sea_orm::DatabaseConnection; use sea_orm_migration::prelude::*; use sea_orm_migration::MigrationTrait; mod m00001_create_ticket; mod m00002_create_session; mod m00003_create_recording; mod m00004_create_known_host; mod m00005_create_log_entry; mod m00006_add_session_protocol; mod m00007_targets_and_roles; mod m00008_users; mod m00009_credential_models; mod m00010_parameters; mod m00011_rsa_key_algos; mod m00012_add_openssh_public_key_label; mod m00013_add_openssh_public_key_dates; mod m00014_api_tokens; mod m00015_fix_public_key_dates; mod m00016_fix_public_key_length; mod m00017_descriptions; mod m00018_ticket_description; mod m00019_rate_limits; mod m00020_target_groups; mod m00021_ldap_server; mod m00022_user_ldap_link; mod m00023_ldap_username_attribute; mod m00024_ssh_key_attribute; mod m00025_ldap_uuid_attribute; mod m00026_ssh_client_auth; mod m00027_ca; mod m00028_certificate_credentials; mod m00029_certificate_revocation; mod m00030_add_recording_metadata; mod m00031_minimize_password_login; mod m00032_admin_roles; pub struct Migrator; #[async_trait::async_trait] impl MigratorTrait for Migrator { fn migrations() -> Vec> { 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), Box::new(m00005_create_log_entry::Migration), Box::new(m00006_add_session_protocol::Migration), Box::new(m00007_targets_and_roles::Migration), Box::new(m00008_users::Migration), Box::new(m00009_credential_models::Migration), Box::new(m00010_parameters::Migration), Box::new(m00011_rsa_key_algos::Migration), Box::new(m00012_add_openssh_public_key_label::Migration), Box::new(m00013_add_openssh_public_key_dates::Migration), Box::new(m00014_api_tokens::Migration), Box::new(m00015_fix_public_key_dates::Migration), Box::new(m00016_fix_public_key_length::Migration), Box::new(m00017_descriptions::Migration), Box::new(m00018_ticket_description::Migration), Box::new(m00019_rate_limits::Migration), Box::new(m00020_target_groups::Migration), Box::new(m00021_ldap_server::Migration), Box::new(m00022_user_ldap_link::Migration), Box::new(m00023_ldap_username_attribute::Migration), Box::new(m00024_ssh_key_attribute::Migration), Box::new(m00025_ldap_uuid_attribute::Migration), Box::new(m00026_ssh_client_auth::Migration), Box::new(m00027_ca::Migration), Box::new(m00028_certificate_credentials::Migration), Box::new(m00029_certificate_revocation::Migration), Box::new(m00030_add_recording_metadata::Migration), Box::new(m00031_minimize_password_login::Migration), Box::new(m00032_admin_roles::Migration), ] } } pub async fn migrate_database(connection: &DatabaseConnection) -> Result<(), DbErr> { Migrator::up(connection, None).await } ================================================ FILE: warpgate-db-migrations/src/m00001_create_ticket.rs ================================================ use sea_orm::{DbBackend, Schema}; use sea_orm_migration::prelude::*; pub mod ticket { use sea_orm::entity::prelude::*; use uuid::Uuid; #[derive(Clone, Debug, PartialEq, Eq, 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, pub expiry: Option, 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?; let connection = manager.get_connection(); if connection.get_database_backend() == DbBackend::MySql { // https://github.com/warp-tech/warpgate/issues/857 connection .execute_unprepared( "ALTER TABLE `tickets` MODIFY COLUMN `expiry` TIMESTAMP NULL DEFAULT NULL", ) .await?; } Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .drop_table(Table::drop().table(ticket::Entity).to_owned()) .await } } ================================================ FILE: warpgate-db-migrations/src/m00002_create_session.rs ================================================ use sea_orm::Schema; use sea_orm_migration::prelude::*; pub mod session { use sea_orm::entity::prelude::*; use uuid::Uuid; use crate::m00001_create_ticket::ticket; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "sessions")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub target_snapshot: Option, pub username: Option, pub remote_address: String, pub started: DateTimeUtc, pub ended: Option, pub ticket_id: Option, } #[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 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 } } ================================================ FILE: warpgate-db-migrations/src/m00003_create_recording.rs ================================================ use sea_orm::Schema; use sea_orm_migration::prelude::*; pub mod recording { use sea_orm::entity::prelude::*; use uuid::Uuid; use crate::m00002_create_session::session; #[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)] #[sea_orm(rs_type = "String", db_type = "String(StringLen::N(16))")] pub enum RecordingKind { #[sea_orm(string_value = "terminal")] Terminal, #[sea_orm(string_value = "traffic")] Traffic, #[sea_orm(string_value = "kubernetes")] Kubernetes, } #[derive(Clone, Debug, PartialEq, Eq, 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, 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 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_table(Table::drop().table(recording::Entity).to_owned()) .await } } ================================================ FILE: warpgate-db-migrations/src/m00004_create_known_host.rs ================================================ use sea_orm::Schema; use sea_orm_migration::prelude::*; pub mod known_host { use sea_orm::entity::prelude::*; use uuid::Uuid; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "known_hosts")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub host: String, pub port: i32, pub key_type: String, pub key_base64: String, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} } pub struct Migration; impl MigrationName for Migration { fn name(&self) -> &str { "m00004_create_known_host" } } #[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(known_host::Entity)) .await } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .drop_table(Table::drop().table(known_host::Entity).to_owned()) .await } } ================================================ FILE: warpgate-db-migrations/src/m00005_create_log_entry.rs ================================================ use sea_orm::Schema; use sea_orm_migration::prelude::*; pub mod log_entry { use chrono::{DateTime, Utc}; use sea_orm::entity::prelude::*; use sea_orm::query::JsonValue; use uuid::Uuid; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "log")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub text: String, pub values: JsonValue, pub timestamp: DateTime, pub session_id: Uuid, pub username: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} } pub struct Migration; impl MigrationName for Migration { fn name(&self) -> &str { "m00005_create_log_entry" } } #[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(log_entry::Entity)) .await?; manager .create_index( Index::create() .table(log_entry::Entity) .name("log_entry__timestamp_session_id") .col(log_entry::Column::Timestamp) .col(log_entry::Column::SessionId) .to_owned(), ) .await?; manager .create_index( Index::create() .table(log_entry::Entity) .name("log_entry__session_id") .col(log_entry::Column::SessionId) .to_owned(), ) .await?; manager .create_index( Index::create() .table(log_entry::Entity) .name("log_entry__username") .col(log_entry::Column::Username) .to_owned(), ) .await } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .drop_table(Table::drop().table(log_entry::Entity).to_owned()) .await } } ================================================ FILE: warpgate-db-migrations/src/m00006_add_session_protocol.rs ================================================ use sea_orm_migration::prelude::*; pub struct Migration; impl MigrationName for Migration { fn name(&self) -> &str { "m00006_add_session_protocol" } } use crate::m00002_create_session::session; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .alter_table( Table::alter() .table(session::Entity) .add_column( ColumnDef::new(Alias::new("protocol")) .string() .not_null() .default("SSH"), ) .to_owned(), ) .await } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .alter_table( Table::alter() .table(session::Entity) .drop_column(Alias::new("protocol")) .to_owned(), ) .await } } ================================================ FILE: warpgate-db-migrations/src/m00007_targets_and_roles.rs ================================================ use sea_orm::Schema; use sea_orm_migration::prelude::*; pub(crate) mod role { use sea_orm::entity::prelude::*; use uuid::Uuid; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "roles")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub name: String, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} } pub mod target { use sea_orm::entity::prelude::*; use uuid::Uuid; #[derive(Debug, PartialEq, Eq, Clone, EnumIter, DeriveActiveEnum)] #[sea_orm(rs_type = "String", db_type = "String(StringLen::N(16))")] pub enum TargetKind { #[sea_orm(string_value = "http")] Http, #[sea_orm(string_value = "mysql")] MySql, #[sea_orm(string_value = "ssh")] Ssh, #[sea_orm(string_value = "web_admin")] WebAdmin, } #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "targets")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub name: String, pub kind: TargetKind, pub options: serde_json::Value, } impl Related for Entity { fn to() -> RelationDef { super::target_role_assignment::Relation::Target.def() } fn via() -> Option { Some(super::target_role_assignment::Relation::Role.def().rev()) } } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} } pub(crate) mod target_role_assignment { use sea_orm::entity::prelude::*; use uuid::Uuid; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "target_roles")] pub struct Model { #[sea_orm(primary_key, auto_increment = true)] pub id: i32, pub target_id: Uuid, pub role_id: Uuid, } #[derive(Copy, Clone, Debug, EnumIter)] pub enum Relation { Target, Role, } impl RelationTrait for Relation { fn def(&self) -> RelationDef { match self { Self::Target => Entity::belongs_to(super::target::Entity) .from(Column::TargetId) .to(super::target::Column::Id) .into(), Self::Role => Entity::belongs_to(super::role::Entity) .from(Column::RoleId) .to(super::role::Column::Id) .into(), } } } impl ActiveModelBehavior for ActiveModel {} } pub struct Migration; impl MigrationName for Migration { fn name(&self) -> &str { "m00007_targets_and_roles" } } #[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(role::Entity)) .await?; manager .create_table(schema.create_table_from_entity(target::Entity)) .await?; manager .create_table(schema.create_table_from_entity(target_role_assignment::Entity)) .await?; Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .drop_table( Table::drop() .table(target_role_assignment::Entity) .to_owned(), ) .await?; manager .drop_table(Table::drop().table(target::Entity).to_owned()) .await?; manager .drop_table(Table::drop().table(role::Entity).to_owned()) .await?; Ok(()) } } ================================================ FILE: warpgate-db-migrations/src/m00008_users.rs ================================================ use sea_orm::Schema; use sea_orm_migration::prelude::*; pub mod user { use sea_orm::entity::prelude::*; use uuid::Uuid; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "users")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub username: String, pub credentials: serde_json::Value, pub credential_policy: serde_json::Value, } impl Related for Entity { fn to() -> RelationDef { super::user_role_assignment::Relation::User.def() } fn via() -> Option { Some(super::user_role_assignment::Relation::Role.def().rev()) } } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} } pub(crate) mod user_role_assignment { use sea_orm::entity::prelude::*; use uuid::Uuid; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "user_roles")] pub struct Model { #[sea_orm(primary_key, auto_increment = true)] pub id: i32, pub user_id: Uuid, pub role_id: Uuid, } #[derive(Copy, Clone, Debug, EnumIter)] pub enum Relation { User, Role, } impl RelationTrait for Relation { fn def(&self) -> RelationDef { match self { Self::User => Entity::belongs_to(super::user::Entity) .from(Column::UserId) .to(super::user::Column::Id) .into(), Self::Role => Entity::belongs_to(crate::m00007_targets_and_roles::role::Entity) .from(Column::RoleId) .to(crate::m00007_targets_and_roles::role::Column::Id) .into(), } } } impl ActiveModelBehavior for ActiveModel {} } pub struct Migration; impl MigrationName for Migration { fn name(&self) -> &str { "m00008_users" } } #[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(user::Entity)) .await?; manager .create_table(schema.create_table_from_entity(user_role_assignment::Entity)) .await?; Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .drop_table(Table::drop().table(user_role_assignment::Entity).to_owned()) .await?; manager .drop_table(Table::drop().table(user::Entity).to_owned()) .await?; Ok(()) } } ================================================ FILE: warpgate-db-migrations/src/m00009_credential_models.rs ================================================ use credential_enum::UserAuthCredential; use sea_orm::{ActiveModelTrait, EntityTrait, Schema, Set}; use sea_orm_migration::prelude::*; use tracing::error; use uuid::Uuid; use super::m00008_users::user as User; pub mod otp_credential { use sea_orm::entity::prelude::*; use sea_orm::sea_query::ForeignKeyAction; use uuid::Uuid; use crate::m00008_users::user as User; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "credentials_otp")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub user_id: Uuid, pub secret_key: Vec, } #[derive(Copy, Clone, Debug, EnumIter)] pub enum Relation { User, } impl RelationTrait for Relation { fn def(&self) -> RelationDef { match self { Self::User => Entity::belongs_to(User::Entity) .from(Column::UserId) .to(User::Column::Id) .on_delete(ForeignKeyAction::Cascade) .into(), } } } impl ActiveModelBehavior for ActiveModel {} } pub mod password_credential { use sea_orm::entity::prelude::*; use sea_orm::sea_query::ForeignKeyAction; use uuid::Uuid; use crate::m00008_users::user as User; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "credentials_password")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub user_id: Uuid, pub argon_hash: String, } #[derive(Copy, Clone, Debug, EnumIter)] pub enum Relation { User, } impl RelationTrait for Relation { fn def(&self) -> RelationDef { match self { Self::User => Entity::belongs_to(User::Entity) .from(Column::UserId) .to(User::Column::Id) .on_delete(ForeignKeyAction::Cascade) .into(), } } } impl ActiveModelBehavior for ActiveModel {} } pub mod public_key_credential { use sea_orm::entity::prelude::*; use sea_orm::sea_query::ForeignKeyAction; use uuid::Uuid; use crate::m00008_users::user as User; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "credentials_public_key")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub user_id: Uuid, #[sea_orm(column_type = "Text")] pub openssh_public_key: String, } #[derive(Copy, Clone, Debug, EnumIter)] pub enum Relation { User, } impl RelationTrait for Relation { fn def(&self) -> RelationDef { match self { Self::User => Entity::belongs_to(User::Entity) .from(Column::UserId) .to(User::Column::Id) .on_delete(ForeignKeyAction::Cascade) .into(), } } } impl ActiveModelBehavior for ActiveModel {} } mod sso_credential { use sea_orm::entity::prelude::*; use sea_orm::sea_query::ForeignKeyAction; use uuid::Uuid; use crate::m00008_users::user as User; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "credentials_sso")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub user_id: Uuid, pub provider: Option, pub email: String, } #[derive(Copy, Clone, Debug, EnumIter)] pub enum Relation { User, } impl RelationTrait for Relation { fn def(&self) -> RelationDef { match self { Self::User => Entity::belongs_to(User::Entity) .from(Column::UserId) .to(User::Column::Id) .on_delete(ForeignKeyAction::Cascade) .into(), } } } impl ActiveModelBehavior for ActiveModel {} } mod credential_enum { use serde::{Deserialize, Serialize}; mod serde_base64_secret { use serde::Serializer; mod serde_base64 { use data_encoding::BASE64; use serde::{Deserialize, Serializer}; pub fn serialize>( bytes: B, serializer: S, ) -> Result { serializer.serialize_str(&BASE64.encode(bytes.as_ref())) } pub fn deserialize<'de, D: serde::Deserializer<'de>, B: From>>( deserializer: D, ) -> Result { let s = String::deserialize(deserializer)?; Ok(BASE64 .decode(s.as_bytes()) .map_err(serde::de::Error::custom)? .into()) } } pub fn serialize( secret: &Vec, serializer: S, ) -> Result { serde_base64::serialize(secret, serializer) } pub fn deserialize<'de, D: serde::Deserializer<'de>>( deserializer: D, ) -> Result, D::Error> { let inner = serde_base64::deserialize(deserializer)?; Ok(inner) } } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] #[serde(tag = "type")] pub enum UserAuthCredential { #[serde(rename = "password")] Password(UserPasswordCredential), #[serde(rename = "publickey")] PublicKey(UserPublicKeyCredential), #[serde(rename = "otp")] Totp(UserTotpCredential), #[serde(rename = "sso")] Sso(UserSsoCredential), } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] pub struct UserPasswordCredential { pub hash: String, } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] pub struct UserPublicKeyCredential { pub key: String, } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] pub struct UserTotpCredential { #[serde(with = "serde_base64_secret")] pub key: Vec, } #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] pub struct UserSsoCredential { pub provider: Option, pub email: String, } } pub struct Migration; impl MigrationName for Migration { fn name(&self) -> &str { "m00009_credential_models" } } #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { let builder = manager.get_database_backend(); let db = manager.get_connection(); let schema = Schema::new(builder); manager .create_table(schema.create_table_from_entity(otp_credential::Entity)) .await?; manager .create_table(schema.create_table_from_entity(password_credential::Entity)) .await?; manager .create_table(schema.create_table_from_entity(public_key_credential::Entity)) .await?; manager .create_table(schema.create_table_from_entity(sso_credential::Entity)) .await?; let users = User::Entity::find().all(db).await?; for user in users { #[allow(clippy::unwrap_used)] let Ok(credentials) = serde_json::from_value::>(user.credentials.clone()) else { error!( "Failed to parse credentials for user {}, value was {:?}", user.id, user.credentials ); continue; }; for credential in credentials { match credential { UserAuthCredential::Password(password) => { let model = password_credential::ActiveModel { id: Set(Uuid::new_v4()), user_id: Set(user.id), argon_hash: Set(password.hash), }; model.insert(db).await?; } UserAuthCredential::PublicKey(key) => { let model = public_key_credential::ActiveModel { id: Set(Uuid::new_v4()), user_id: Set(user.id), openssh_public_key: Set(key.key), }; model.insert(db).await?; } UserAuthCredential::Sso(sso) => { let model = sso_credential::ActiveModel { id: Set(Uuid::new_v4()), user_id: Set(user.id), provider: Set(sso.provider), email: Set(sso.email), }; model.insert(db).await?; } UserAuthCredential::Totp(totp) => { let model = otp_credential::ActiveModel { id: Set(Uuid::new_v4()), user_id: Set(user.id), secret_key: Set(totp.key), }; model.insert(db).await?; } } } } manager .alter_table( Table::alter() .table(User::Entity) .drop_column(User::Column::Credentials) .to_owned(), ) .await?; Ok(()) } #[allow(clippy::panic, reason = "dev only")] async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> { panic!("This migration is irreversible"); } } ================================================ FILE: warpgate-db-migrations/src/m00010_parameters.rs ================================================ use sea_orm::Schema; use sea_orm_migration::prelude::*; pub mod parameters { use sea_orm::entity::prelude::*; use uuid::Uuid; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "parameters")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub allow_own_credential_management: bool, } impl ActiveModelBehavior for ActiveModel {} #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} } pub struct Migration; impl MigrationName for Migration { fn name(&self) -> &str { "m00010_parameters" } } #[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(parameters::Entity)) .await?; Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .drop_table(Table::drop().table(parameters::Entity).to_owned()) .await?; Ok(()) } } ================================================ FILE: warpgate-db-migrations/src/m00011_rsa_key_algos.rs ================================================ use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, Set}; use sea_orm_migration::prelude::*; use tracing::error; use crate::m00009_credential_models::public_key_credential as PKC; pub struct Migration; impl MigrationName for Migration { fn name(&self) -> &str { "m00011_rsa_key_algos" } } /// Re-save all keys so that rsa-sha2-* gets replaced with ssh-rsa /// since ssh-keys never serializes key type as rsa-sha2-* #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { let connection = manager.get_connection(); let creds = PKC::Entity::find().all(connection).await?; for cred in creds.into_iter() { let parsed = match russh::keys::PublicKey::from_openssh(&cred.openssh_public_key) { Ok(parsed) => parsed, Err(e) => { error!("Failed to parse public key '{cred:?}': {e}"); continue; } }; let serialized = parsed .to_openssh() .map_err(|e| DbErr::Custom(format!("Failed to serialize public key: {e}")))?; let am = PKC::ActiveModel { openssh_public_key: Set(serialized), ..cred.into_active_model() }; am.update(connection).await?; } Ok(()) } async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> { Ok(()) } } ================================================ FILE: warpgate-db-migrations/src/m00012_add_openssh_public_key_label.rs ================================================ use sea_orm_migration::prelude::*; pub struct Migration; impl MigrationName for Migration { fn name(&self) -> &str { "m00012_add_openssh_public_key_label" } } use crate::m00009_credential_models::public_key_credential; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .alter_table( Table::alter() .table(public_key_credential::Entity) .add_column( ColumnDef::new(Alias::new("label")) .string() .not_null() .default("Public Key"), ) .to_owned(), ) .await } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .alter_table( Table::alter() .table(public_key_credential::Entity) .drop_column(Alias::new("label")) .to_owned(), ) .await } } ================================================ FILE: warpgate-db-migrations/src/m00013_add_openssh_public_key_dates.rs ================================================ use sea_orm_migration::prelude::*; pub struct Migration; impl MigrationName for Migration { fn name(&self) -> &str { "m00013_add_openssh_public_key_dates" } } use crate::m00009_credential_models::public_key_credential; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { // Add 'date_added' column manager .alter_table( Table::alter() .table(public_key_credential::Entity) .add_column(ColumnDef::new(Alias::new("date_added")).date_time().null()) .to_owned(), ) .await?; // Add 'last_used' column manager .alter_table( Table::alter() .table(public_key_credential::Entity) .add_column(ColumnDef::new(Alias::new("last_used")).date_time().null()) .to_owned(), ) .await?; Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { // Drop 'last_used' column manager .alter_table( Table::alter() .table(public_key_credential::Entity) .drop_column(Alias::new("last_used")) .to_owned(), ) .await?; // Drop 'date_added' column manager .alter_table( Table::alter() .table(public_key_credential::Entity) .drop_column(Alias::new("date_added")) .to_owned(), ) .await?; Ok(()) } } ================================================ FILE: warpgate-db-migrations/src/m00014_api_tokens.rs ================================================ use sea_orm::Schema; use sea_orm_migration::prelude::*; use super::m00008_users::user as User; pub mod api_tokens { use chrono::{DateTime, Utc}; use sea_orm::entity::prelude::*; use sea_orm::sea_query::ForeignKeyAction; use serde::Serialize; use uuid::Uuid; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)] #[sea_orm(table_name = "api_tokens")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub user_id: Uuid, pub label: String, pub secret: String, pub created: DateTime, pub expiry: DateTime, } #[derive(Copy, Clone, Debug, EnumIter)] pub enum Relation { User, } impl RelationTrait for Relation { fn def(&self) -> RelationDef { match self { Self::User => Entity::belongs_to(super::User::Entity) .from(Column::UserId) .to(super::User::Column::Id) .on_delete(ForeignKeyAction::Cascade) .into(), } } } impl Related for Entity { fn to() -> RelationDef { Relation::User.def() } } impl ActiveModelBehavior for ActiveModel {} } pub struct Migration; impl MigrationName for Migration { fn name(&self) -> &str { "m00014_api_tokens" } } #[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(api_tokens::Entity)) .await?; Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .drop_table(Table::drop().table(api_tokens::Entity).to_owned()) .await?; Ok(()) } } ================================================ FILE: warpgate-db-migrations/src/m00015_fix_public_key_dates.rs ================================================ use sea_orm::DbBackend; use sea_orm_migration::prelude::*; /// timestamp_with_time_zone() was originally missing on these columns pub struct Migration; impl MigrationName for Migration { fn name(&self) -> &str { "m00015_fix_public_key_dates" } } use crate::m00009_credential_models::public_key_credential; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { let connection = manager.get_connection(); if connection.get_database_backend() != DbBackend::Sqlite { manager .alter_table( Table::alter() .table(public_key_credential::Entity) .modify_column( ColumnDef::new(Alias::new("date_added")) .date_time() .timestamp_with_time_zone() .null(), ) .to_owned(), ) .await?; manager .alter_table( Table::alter() .table(public_key_credential::Entity) .modify_column( ColumnDef::new(Alias::new("last_used")) .date_time() .timestamp_with_time_zone() .null(), ) .to_owned(), ) .await?; } Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { let connection = manager.get_connection(); if connection.get_database_backend() != DbBackend::Sqlite { manager .alter_table( Table::alter() .table(public_key_credential::Entity) .modify_column(ColumnDef::new(Alias::new("date_added")).date_time().null()) .to_owned(), ) .await?; manager .alter_table( Table::alter() .table(public_key_credential::Entity) .modify_column(ColumnDef::new(Alias::new("last_used")).date_time().null()) .to_owned(), ) .await?; } Ok(()) } } ================================================ FILE: warpgate-db-migrations/src/m00016_fix_public_key_length.rs ================================================ use sea_orm::DbBackend; use sea_orm_migration::prelude::*; /// The original column type was `String` which defaults to VARCHAR(255) on MySQL pub struct Migration; impl MigrationName for Migration { fn name(&self) -> &str { "m00016_fix_public_key_length" } } use crate::m00009_credential_models::public_key_credential; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { let connection = manager.get_connection(); if connection.get_database_backend() != DbBackend::Sqlite { manager .alter_table( Table::alter() .table(public_key_credential::Entity) .modify_column(ColumnDef::new(Alias::new("openssh_public_key")).text()) .to_owned(), ) .await?; } Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { let connection = manager.get_connection(); if connection.get_database_backend() != DbBackend::Sqlite { manager .alter_table( Table::alter() .table(public_key_credential::Entity) .modify_column(ColumnDef::new(Alias::new("openssh_public_key")).string()) .to_owned(), ) .await?; } Ok(()) } } ================================================ FILE: warpgate-db-migrations/src/m00017_descriptions.rs ================================================ use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; use sea_orm_migration::prelude::*; pub struct Migration; impl MigrationName for Migration { fn name(&self) -> &str { "m00017_descriptions" } } use crate::m00007_targets_and_roles::target; use crate::m00008_users::user; pub mod role { use sea_orm::entity::prelude::*; use serde::Serialize; use uuid::Uuid; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)] #[sea_orm(table_name = "roles")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub name: String, #[sea_orm(column_type = "Text")] pub description: String, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} } #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .alter_table( Table::alter() .table(user::Entity) .add_column( ColumnDef::new(Alias::new("description")) .text() .not_null() .default(""), ) .to_owned(), ) .await?; manager .alter_table( Table::alter() .table(role::Entity) .add_column( ColumnDef::new(Alias::new("description")) .text() .not_null() .default(""), ) .to_owned(), ) .await?; manager .alter_table( Table::alter() .table(target::Entity) .add_column( ColumnDef::new(Alias::new("description")) .text() .not_null() .default(""), ) .to_owned(), ) .await?; // set description for builtin admin role if let Some(admin_role) = role::Entity::find() .filter(role::Column::Name.eq("warpgate:admin")) .one(manager.get_connection()) .await? { role::ActiveModel { id: Set(admin_role.id), description: Set("Built-in admin role".into()), name: Set(admin_role.name), } .update(manager.get_connection()) .await?; } Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .alter_table( Table::alter() .table(target::Entity) .drop_column(Alias::new("description")) .to_owned(), ) .await?; manager .alter_table( Table::alter() .table(user::Entity) .drop_column(Alias::new("description")) .to_owned(), ) .await?; manager .alter_table( Table::alter() .table(role::Entity) .drop_column(Alias::new("description")) .to_owned(), ) .await?; Ok(()) } } ================================================ FILE: warpgate-db-migrations/src/m00018_ticket_description.rs ================================================ use sea_orm_migration::prelude::*; use crate::m00001_create_ticket::ticket; pub struct Migration; impl MigrationName for Migration { fn name(&self) -> &str { "m00018_ticket_description" } } #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .alter_table( Table::alter() .table(ticket::Entity) .add_column( ColumnDef::new(Alias::new("description")) .text() .not_null() .default(""), ) .to_owned(), ) .await?; Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .alter_table( Table::alter() .table(ticket::Entity) .drop_column(Alias::new("description")) .to_owned(), ) .await?; Ok(()) } } ================================================ FILE: warpgate-db-migrations/src/m00019_rate_limits.rs ================================================ use sea_orm_migration::prelude::*; pub struct Migration; impl MigrationName for Migration { fn name(&self) -> &str { "m00019_rate_limits" } } use crate::m00007_targets_and_roles::target; use crate::m00008_users::user; use crate::m00010_parameters::parameters; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .alter_table( Table::alter() .table(user::Entity) .add_column( ColumnDef::new(Alias::new("rate_limit_bytes_per_second")) .big_integer() .null(), ) .to_owned(), ) .await?; manager .alter_table( Table::alter() .table(parameters::Entity) .add_column( ColumnDef::new(Alias::new("rate_limit_bytes_per_second")) .big_integer() .null(), ) .to_owned(), ) .await?; manager .alter_table( Table::alter() .table(target::Entity) .add_column( ColumnDef::new(Alias::new("rate_limit_bytes_per_second")) .big_integer() .null(), ) .to_owned(), ) .await?; Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .alter_table( Table::alter() .table(target::Entity) .drop_column(Alias::new("rate_limit_bytes_per_second")) .to_owned(), ) .await?; manager .alter_table( Table::alter() .table(user::Entity) .drop_column(Alias::new("rate_limit_bytes_per_second")) .to_owned(), ) .await?; manager .alter_table( Table::alter() .table(parameters::Entity) .drop_column(Alias::new("rate_limit_bytes_per_second")) .to_owned(), ) .await?; Ok(()) } } ================================================ FILE: warpgate-db-migrations/src/m00020_target_groups.rs ================================================ use sea_orm::Schema; use sea_orm_migration::prelude::*; use crate::m00007_targets_and_roles::target; pub mod target_group { use sea_orm::entity::prelude::*; use uuid::Uuid; #[derive(Debug, PartialEq, Eq, Clone, EnumIter, DeriveActiveEnum)] #[sea_orm(rs_type = "String", db_type = "String(StringLen::N(16))")] pub enum BootstrapThemeColor { #[sea_orm(string_value = "primary")] Primary, #[sea_orm(string_value = "secondary")] Secondary, #[sea_orm(string_value = "success")] Success, #[sea_orm(string_value = "danger")] Danger, #[sea_orm(string_value = "warning")] Warning, #[sea_orm(string_value = "info")] Info, #[sea_orm(string_value = "light")] Light, #[sea_orm(string_value = "dark")] Dark, } #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "target_groups")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub name: String, #[sea_orm(column_type = "Text")] pub description: String, pub color: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} } pub struct Migration; impl MigrationName for Migration { fn name(&self) -> &str { "m00020_target_groups" } } #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { // Create target_groups table let builder = manager.get_database_backend(); let schema = Schema::new(builder); manager .create_table(schema.create_table_from_entity(target_group::Entity)) .await?; // Add group_id column to targets table manager .alter_table( Table::alter() .table(target::Entity) .add_column(ColumnDef::new(Alias::new("group_id")).uuid().null()) .to_owned(), ) .await?; // Note: SQLite doesn't support adding foreign key constraints to existing tables // The foreign key relationship will be enforced at the application level Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { // Remove group_id column from targets table manager .alter_table( Table::alter() .table(target::Entity) .drop_column(Alias::new("group_id")) .to_owned(), ) .await?; // Drop target_groups table manager .drop_table(Table::drop().table(target_group::Entity).to_owned()) .await?; Ok(()) } } ================================================ FILE: warpgate-db-migrations/src/m00021_ldap_server.rs ================================================ use sea_orm::Schema; use sea_orm_migration::prelude::*; pub mod ldap_server { use sea_orm::entity::prelude::*; use uuid::Uuid; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "ldap_servers")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, #[sea_orm(unique)] pub name: String, pub host: String, pub port: i32, pub bind_dn: String, pub bind_password: String, pub user_filter: String, pub base_dns: serde_json::Value, pub tls_mode: String, pub tls_verify: bool, pub enabled: bool, pub auto_link_sso_users: bool, #[sea_orm(column_type = "Text")] pub description: String, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} } pub struct Migration; impl MigrationName for Migration { fn name(&self) -> &str { "m00020_ldap_server" } } #[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(ldap_server::Entity)) .await?; Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .drop_table(Table::drop().table(ldap_server::Entity).to_owned()) .await?; Ok(()) } } ================================================ FILE: warpgate-db-migrations/src/m00022_user_ldap_link.rs ================================================ use sea_orm_migration::prelude::*; pub struct Migration; pub mod user { use sea_orm::entity::prelude::*; use uuid::Uuid; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "users")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub username: String, pub credential_policy: serde_json::Value, #[sea_orm(column_type = "Text")] pub description: String, pub rate_limit_bytes_per_second: Option, pub ldap_server_id: Option, #[sea_orm(column_type = "Text", nullable)] pub ldap_object_uuid: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} } impl MigrationName for Migration { fn name(&self) -> &str { "m00021_user_ldap_link" } } #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .alter_table( Table::alter() .table(user::Entity) .add_column(ColumnDef::new(user::Column::LdapServerId).uuid().null()) .to_owned(), ) .await?; manager .alter_table( Table::alter() .table(user::Entity) .add_column(ColumnDef::new(user::Column::LdapObjectUuid).text().null()) .to_owned(), ) .await?; // Add index on ldap_server_id for foreign key-like lookups manager .create_index( Index::create() .name("idx_users_ldap_server_id") .table(user::Entity) .col(user::Column::LdapServerId) .to_owned(), ) .await?; // Add unique index on (ldap_server_id, ldap_object_uuid) to ensure no duplicates manager .create_index( Index::create() .name("idx_users_ldap_unique") .table(user::Entity) .col(user::Column::LdapServerId) .col(user::Column::LdapObjectUuid) .unique() .to_owned(), ) .await?; Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .drop_index( Index::drop() .name("idx_users_ldap_unique") .table(user::Entity) .to_owned(), ) .await?; manager .drop_index( Index::drop() .name("idx_users_ldap_server_id") .table(user::Entity) .to_owned(), ) .await?; manager .alter_table( Table::alter() .table(user::Entity) .drop_column(user::Column::LdapObjectUuid) .to_owned(), ) .await?; manager .alter_table( Table::alter() .table(user::Entity) .drop_column(user::Column::LdapServerId) .to_owned(), ) .await?; Ok(()) } } ================================================ FILE: warpgate-db-migrations/src/m00023_ldap_username_attribute.rs ================================================ use sea_orm_migration::prelude::*; use super::m00021_ldap_server::ldap_server; pub struct Migration; impl MigrationName for Migration { fn name(&self) -> &str { "m00023_ldap_username_attribute" } } #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .alter_table( Table::alter() .table(ldap_server::Entity) .add_column( ColumnDef::new(Alias::new("username_attribute")) .string() .not_null() .default("uid"), ) .to_owned(), ) .await?; Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .alter_table( Table::alter() .table(ldap_server::Entity) .drop_column(Alias::new("username_attribute")) .to_owned(), ) .await } } ================================================ FILE: warpgate-db-migrations/src/m00024_ssh_key_attribute.rs ================================================ use sea_orm_migration::prelude::*; use super::m00021_ldap_server::ldap_server; #[derive(DeriveMigrationName)] pub struct Migration; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .alter_table( Table::alter() .table(ldap_server::Entity) .add_column_if_not_exists( ColumnDef::new(Alias::new("ssh_key_attribute")) .string() .not_null() .default("sshPublicKey"), ) .to_owned(), ) .await } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .alter_table( Table::alter() .table(ldap_server::Entity) .drop_column(Alias::new("ssh_key_attribute")) .to_owned(), ) .await } } ================================================ FILE: warpgate-db-migrations/src/m00025_ldap_uuid_attribute.rs ================================================ use sea_orm_migration::prelude::*; use super::m00021_ldap_server::ldap_server; #[derive(DeriveMigrationName)] pub struct Migration; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .alter_table( Table::alter() .table(ldap_server::Entity) .add_column_if_not_exists( ColumnDef::new(Alias::new("uuid_attribute")) .string() .not_null() .default(""), ) .to_owned(), ) .await } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .alter_table( Table::alter() .table(ldap_server::Entity) .drop_column(Alias::new("uuid_attribute")) .to_owned(), ) .await } } ================================================ FILE: warpgate-db-migrations/src/m00026_ssh_client_auth.rs ================================================ use sea_orm_migration::prelude::*; pub struct Migration; impl MigrationName for Migration { fn name(&self) -> &str { "m00025_ssh_client_auth" } } use crate::m00010_parameters::parameters; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .alter_table( Table::alter() .table(parameters::Entity) .add_column( ColumnDef::new(Alias::new("ssh_client_auth_publickey")) .boolean() .not_null() .default(true), ) .to_owned(), ) .await?; manager .alter_table( Table::alter() .table(parameters::Entity) .add_column( ColumnDef::new(Alias::new("ssh_client_auth_password")) .boolean() .not_null() .default(true), ) .to_owned(), ) .await?; manager .alter_table( Table::alter() .table(parameters::Entity) .add_column( ColumnDef::new(Alias::new("ssh_client_auth_keyboard_interactive")) .boolean() .not_null() .default(true), ) .to_owned(), ) .await?; Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .alter_table( Table::alter() .table(parameters::Entity) .drop_column(Alias::new("ssh_client_auth_keyboard_interactive")) .to_owned(), ) .await?; manager .alter_table( Table::alter() .table(parameters::Entity) .drop_column(Alias::new("ssh_client_auth_password")) .to_owned(), ) .await?; manager .alter_table( Table::alter() .table(parameters::Entity) .drop_column(Alias::new("ssh_client_auth_publickey")) .to_owned(), ) .await?; Ok(()) } } ================================================ FILE: warpgate-db-migrations/src/m00027_ca.rs ================================================ use sea_orm::ActiveValue::Set; use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel}; use sea_orm_migration::prelude::*; use tracing::info; use uuid::Uuid; pub mod parameters { use sea_orm::entity::prelude::*; use uuid::Uuid; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "parameters")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub allow_own_credential_management: bool, pub rate_limit_bytes_per_second: Option, #[sea_orm(column_type = "Text")] pub ca_certificate_pem: String, #[sea_orm(column_type = "Text")] pub ca_private_key_pem: String, } impl ActiveModelBehavior for ActiveModel {} #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} } #[derive(DeriveMigrationName)] pub struct Migration; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .alter_table( Table::alter() .table(parameters::Entity) .add_column( ColumnDef::new(Alias::new("ca_certificate_pem")) .text() .not_null() .default(""), ) .to_owned(), ) .await?; manager .alter_table( Table::alter() .table(parameters::Entity) .add_column( ColumnDef::new(Alias::new("ca_private_key_pem")) .text() .not_null() .default(""), ) .to_owned(), ) .await?; info!("Generating root CA certificate"); let (cert_pem, pk_pem) = warpgate_ca::generate_root_certificate_rcgen() .map_err(|e| DbErr::Custom(format!("Failed to generate CA certificate: {}", e)))?; let db = manager.get_connection(); let parameters = match parameters::Entity::find().one(db).await? { Some(model) => Ok(model), None => { parameters::ActiveModel { id: Set(Uuid::new_v4()), allow_own_credential_management: Set(true), rate_limit_bytes_per_second: Set(None), ca_certificate_pem: Set("".into()), ca_private_key_pem: Set("".into()), } .insert(db) .await } }?; let mut model = parameters.into_active_model(); model.ca_certificate_pem = Set(cert_pem); model.ca_private_key_pem = Set(pk_pem); model.update(db).await?; Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .alter_table( Table::alter() .table(parameters::Entity) .drop_column(Alias::new("ca_certificate_pem")) .to_owned(), ) .await?; manager .alter_table( Table::alter() .table(parameters::Entity) .drop_column(Alias::new("ca_private_key_pem")) .to_owned(), ) .await?; Ok(()) } } ================================================ FILE: warpgate-db-migrations/src/m00028_certificate_credentials.rs ================================================ use chrono::{DateTime, Utc}; use sea_orm::entity::prelude::*; use sea_orm::sea_query::ForeignKeyAction; use sea_orm::Schema; use sea_orm_migration::prelude::*; use serde::Serialize; use uuid::Uuid; use super::m00008_users::user as User; pub mod certificate_credential { use super::*; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)] #[sea_orm(table_name = "credentials_certificate")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub user_id: Uuid, pub label: String, pub date_added: Option>, pub last_used: Option>, #[sea_orm(column_type = "Text")] pub certificate_pem: String, } #[derive(Copy, Clone, Debug, EnumIter)] pub enum Relation { User, } impl RelationTrait for Relation { fn def(&self) -> RelationDef { match self { Self::User => Entity::belongs_to(super::User::Entity) .from(Column::UserId) .to(super::User::Column::Id) .on_delete(ForeignKeyAction::Cascade) .into(), } } } impl Related for Entity { fn to() -> RelationDef { Relation::User.def() } } impl ActiveModelBehavior for ActiveModel {} } #[derive(DeriveMigrationName)] pub struct Migration; #[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(certificate_credential::Entity)) .await?; Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .drop_table( Table::drop() .table(certificate_credential::Entity) .to_owned(), ) .await?; Ok(()) } } ================================================ FILE: warpgate-db-migrations/src/m00029_certificate_revocation.rs ================================================ use sea_orm::entity::prelude::*; use sea_orm::Schema; use sea_orm_migration::prelude::*; pub mod certificate_revocation { use chrono::{DateTime, Utc}; use sea_orm::entity::prelude::*; use serde::Serialize; use uuid::Uuid; use super::*; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)] #[sea_orm(table_name = "certificate_revocations")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub serial_number_base64: String, pub date_added: DateTime, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} } #[derive(DeriveMigrationName)] pub struct Migration; #[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(certificate_revocation::Entity)) .await?; manager .create_index( Index::create() .name("idx_certificate_revocations_serial") .table(certificate_revocation::Entity) .col(certificate_revocation::Column::SerialNumberBase64) .to_owned(), ) .await?; Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .drop_index( Index::drop() .name("idx_certificate_revocations_serial") .table(certificate_revocation::Entity) .to_owned(), ) .await?; manager .drop_table( Table::drop() .table(certificate_revocation::Entity) .to_owned(), ) .await?; Ok(()) } } ================================================ FILE: warpgate-db-migrations/src/m00030_add_recording_metadata.rs ================================================ use sea_orm_migration::prelude::*; pub struct Migration; use super::m00003_create_recording::recording; impl MigrationName for Migration { fn name(&self) -> &str { "m00030_add_recording_metadata" } } #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .alter_table( Table::alter() .table(recording::Entity) .add_column( ColumnDef::new(Alias::new("metadata")) .text() .default("null"), ) .to_owned(), ) .await } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .alter_table( Table::alter() .table(recording::Entity) .drop_column(Alias::new("metadata")) .to_owned(), ) .await } } ================================================ FILE: warpgate-db-migrations/src/m00031_minimize_password_login.rs ================================================ use sea_orm_migration::prelude::*; use crate::m00010_parameters::parameters; pub struct Migration; impl MigrationName for Migration { fn name(&self) -> &str { "m00031_minimize_password_login" } } #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .alter_table( Table::alter() .table(parameters::Entity) .add_column( ColumnDef::new(Alias::new("minimize_password_login")) .boolean() .not_null() .default(false), ) .to_owned(), ) .await?; Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .alter_table( Table::alter() .table(parameters::Entity) .drop_column(Alias::new("minimize_password_login")) .to_owned(), ) .await?; Ok(()) } } ================================================ FILE: warpgate-db-migrations/src/m00032_admin_roles.rs ================================================ use sea_orm::prelude::*; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Schema, Set}; use sea_orm_migration::prelude::*; use uuid::Uuid; use crate::m00007_targets_and_roles::{target, target_role_assignment}; use crate::m00008_users::user_role_assignment; use crate::m00022_user_ldap_link::user; pub(crate) mod admin_role { use super::*; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "admin_roles")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, pub name: String, #[sea_orm(column_type = "Text")] pub description: String, pub targets_create: bool, pub targets_edit: bool, pub targets_delete: bool, pub users_create: bool, pub users_edit: bool, pub users_delete: bool, pub access_roles_create: bool, pub access_roles_edit: bool, pub access_roles_delete: bool, pub access_roles_assign: bool, pub sessions_view: bool, pub sessions_terminate: bool, pub recordings_view: bool, pub tickets_create: bool, pub tickets_delete: bool, pub config_edit: bool, pub admin_roles_manage: bool, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation {} impl ActiveModelBehavior for ActiveModel {} } pub(crate) mod user_admin_role { use super::*; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "user_admin_roles")] pub struct Model { #[sea_orm(primary_key, auto_increment = true)] pub id: i32, pub user_id: Uuid, pub admin_role_id: Uuid, } #[derive(Copy, Clone, Debug, EnumIter)] pub enum Relation { User, AdminRole, } impl RelationTrait for Relation { fn def(&self) -> RelationDef { match self { Self::User => Entity::belongs_to(user::Entity) .from(Column::UserId) .to(user::Column::Id) .into(), Self::AdminRole => Entity::belongs_to(super::admin_role::Entity) .from(Column::AdminRoleId) .to(super::admin_role::Column::Id) .into(), } } } impl ActiveModelBehavior for ActiveModel {} } pub struct Migration; impl MigrationName for Migration { fn name(&self) -> &str { "m00032_admin_roles" } } #[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(admin_role::Entity)) .await?; manager .create_table(schema.create_table_from_entity(user_admin_role::Entity)) .await?; // migrate existing warpgate:admin users to a new admin role with full permissions let conn = manager.get_connection(); // find all users authorized for the web admin target without complex joins let admin_targets = target::Entity::find() .filter(target::Column::Kind.eq(target::TargetKind::WebAdmin)) .all(conn) .await?; let mut admin_users: Vec = Vec::new(); if !admin_targets.is_empty() { let web_target_ids: Vec = admin_targets.into_iter().map(|t| t.id).collect(); let web_role_assignments = target_role_assignment::Entity::find() .filter(target_role_assignment::Column::TargetId.is_in(web_target_ids.clone())) .all(conn) .await?; let web_role_ids: Vec = web_role_assignments .into_iter() .map(|a| a.role_id) .collect(); if !web_role_ids.is_empty() { // now get all users that have one of those roles let user_assignments = user_role_assignment::Entity::find() .filter(user_role_assignment::Column::RoleId.is_in(web_role_ids.clone())) .all(conn) .await?; if !user_assignments.is_empty() { let user_ids: Vec = user_assignments.into_iter().map(|a| a.user_id).collect(); admin_users = user::Entity::find() .filter(user::Column::Id.is_in(user_ids.clone())) .all(conn) .await?; } } } let builtin_admin_role_id = Uuid::new_v4(); let values = admin_role::ActiveModel { id: Set(builtin_admin_role_id), name: Set("warpgate:admin".to_string()), description: Set("Built-in admin role".into()), targets_create: Set(true), targets_edit: Set(true), targets_delete: Set(true), users_create: Set(true), users_edit: Set(true), users_delete: Set(true), access_roles_create: Set(true), access_roles_edit: Set(true), access_roles_delete: Set(true), access_roles_assign: Set(true), sessions_view: Set(true), sessions_terminate: Set(true), recordings_view: Set(true), tickets_create: Set(true), tickets_delete: Set(true), config_edit: Set(true), admin_roles_manage: Set(true), }; values.insert(conn).await?; for user in admin_users { let assign = user_admin_role::ActiveModel { user_id: Set(user.id), admin_role_id: Set(builtin_admin_role_id), ..Default::default() }; assign.insert(conn).await?; } // drop the old web-admin target and its assignments as it's no longer used { if let Some(web_target) = target::Entity::find() .filter(target::Column::Kind.eq(target::TargetKind::WebAdmin)) .one(conn) .await? { target_role_assignment::Entity::delete_many() .filter(target_role_assignment::Column::TargetId.eq(web_target.id)) .exec(conn) .await?; target::Entity::delete_by_id(web_target.id) .exec(conn) .await?; } } Ok(()) } async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> { panic!("This migration cannot be reversed"); } } ================================================ FILE: warpgate-db-migrations/src/main.rs ================================================ use sea_orm_migration::prelude::*; use warpgate_db_migrations::Migrator; #[tokio::main] async fn main() { cli::run_cli(Migrator).await; } ================================================ FILE: warpgate-ldap/Cargo.toml ================================================ [package] edition = "2021" license = "Apache-2.0" name = "warpgate-ldap" version = "0.22.0" [dependencies] anyhow.workspace = true ldap3.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true poem-openapi.workspace = true tokio.workspace = true tracing.workspace = true uuid.workspace = true warpgate-tls = { version = "*", path = "../warpgate-tls", default-features = false } ================================================ FILE: warpgate-ldap/src/connection.rs ================================================ use std::sync::Arc; use std::time::Duration; use ldap3::{Ldap, LdapConnAsync, LdapConnSettings, Scope, SearchEntry}; use tracing::{debug, info, warn}; use warpgate_tls::{configure_tls_connector, TlsMode}; use crate::error::{LdapError, Result}; use crate::types::LdapConfig; pub async fn connect(config: &LdapConfig) -> Result { let url = build_ldap_url(config); debug!("Connecting to LDAP server: {}", url); let connector = Arc::new(configure_tls_connector(!config.tls_verify, false, None).await?); // Configure connection settings based on TLS mode let settings = LdapConnSettings::new() .set_starttls( matches!(config.tls_mode, TlsMode::Preferred | TlsMode::Required) && config.port != 636, ) .set_conn_timeout(Duration::from_secs(10)) .set_no_tls_verify(!config.tls_verify) .set_config(connector); let (conn, mut ldap) = LdapConnAsync::with_settings(settings, &url) .await .map_err(|e| LdapError::ConnectionFailed(e.to_string()))?; tokio::spawn(async move { if let Err(e) = conn.drive().await { warn!("LDAP connection driver error: {}", e); } }); // Bind with credentials ldap.simple_bind(&config.bind_dn, &config.bind_password) .await .map_err(|e| LdapError::AuthenticationFailed(e.to_string()))? .success() .map_err(|e| LdapError::AuthenticationFailed(e.to_string()))?; Ok(ldap) } pub async fn test_connection(config: &LdapConfig) -> Result { match connect(config).await { Ok(mut ldap) => { // Try to unbind cleanly let _ = ldap.unbind().await; Ok(true) } Err(e) => { debug!("Test connection failed: {}", e); Err(e) } } } pub async fn discover_base_dns(config: &LdapConfig) -> Result> { let mut ldap = connect(config).await?; debug!("Querying rootDSE for naming contexts"); // Query rootDSE for namingContexts let (rs, _res) = ldap .search("", Scope::Base, "(objectClass=*)", vec!["namingContexts"]) .await .map_err(|e| LdapError::QueryFailed(e.to_string()))? .success() .map_err(|e| LdapError::QueryFailed(e.to_string()))?; let mut base_dns = Vec::new(); for entry in rs { let entry = SearchEntry::construct(entry); if let Some(contexts) = entry.attrs.get("namingContexts") { for context in contexts { if !context.is_empty() { base_dns.push(context.clone()); } } } } let _ = ldap.unbind().await; if base_dns.is_empty() { warn!("No naming contexts found in rootDSE"); } else { info!("Discovered {} base DN(s): {:?}", base_dns.len(), base_dns); } Ok(base_dns) } fn build_ldap_url(config: &LdapConfig) -> String { let scheme = match (&config.tls_mode, config.port) { (TlsMode::Disabled, _) => "ldap", (_, 636) => "ldaps", _ => "ldap", }; format!("{}://{}:{}", scheme, config.host, config.port) } ================================================ FILE: warpgate-ldap/src/error.rs ================================================ use thiserror::Error; use warpgate_tls::RustlsSetupError; pub type Result = std::result::Result; #[derive(Error, Debug)] pub enum LdapError { #[error("LDAP connection failed: {0}")] ConnectionFailed(String), #[error("LDAP authentication failed: {0}")] AuthenticationFailed(String), #[error("LDAP query failed: {0}")] QueryFailed(String), #[error("TLS error: {0}")] TlsError(String), #[error("Invalid configuration: {0}")] InvalidConfiguration(String), #[error("LDAP error: {0}")] LdapClientError(#[from] Box), #[error("JSON error: {0}")] JsonError(#[from] serde_json::Error), #[error("rustls setup: {0}")] RustlSetup(#[from] RustlsSetupError), #[error("cannot determine username for user DN: {0}")] NoUsername(String), #[error("cannot determine UUID for user DN: {0}")] NoUUID(String), #[error("Other error: {0}")] Other(String), } impl From for LdapError { fn from(s: String) -> Self { LdapError::Other(s) } } ================================================ FILE: warpgate-ldap/src/lib.rs ================================================ mod connection; mod error; mod queries; mod types; pub use connection::{connect, discover_base_dns, test_connection}; pub use error::{LdapError, Result}; pub use queries::{find_user_by_username, find_user_by_uuid, list_users}; pub use types::{LdapConfig, LdapUser, LdapUsernameAttribute}; ================================================ FILE: warpgate-ldap/src/queries.rs ================================================ use std::collections::HashSet; use std::fmt::Write; use ldap3::{Scope, SearchEntry}; use tracing::{debug, warn}; use uuid::Uuid; use crate::connection::connect; use crate::error::{LdapError, Result}; use crate::types::{LdapConfig, LdapUser}; fn ldap_user_attributes(config: &LdapConfig) -> Vec { let mut attrs: Vec = vec![ "mail".into(), "displayName".into(), "userPrincipalName".into(), ]; // Add UUID attributes - either custom or default ones if let Some(custom_uuid_attr) = &config.uuid_attribute { if !attrs.contains(custom_uuid_attr) { attrs.push(custom_uuid_attr.clone()); } } else { // Default behavior: query both objectGUID and entryUUID attrs.push("objectGUID".into()); attrs.push("entryUUID".into()); } let username_attribute = config.username_attribute.attribute_name().to_string(); if !attrs.contains(&username_attribute) { attrs.push(username_attribute); } if !attrs.contains(&config.ssh_key_attribute) { attrs.push(config.ssh_key_attribute.clone()); } attrs } /// Extract user details from an LDAP [SearchEntry]. /// Returns None if no valid username or UUID can be determined. fn extract_ldap_user(search_entry: SearchEntry, config: &LdapConfig) -> Result { let dn = search_entry.dn.clone(); // Extract username - try different attributes let username = search_entry .attrs .get(config.username_attribute.attribute_name()) .and_then(|v| v.first()) .cloned() .ok_or(LdapError::NoUsername(dn.clone()))?; let email = search_entry .attrs .get("mail") .and_then(|v| v.first()) .cloned(); let display_name = search_entry .attrs .get("displayName") .and_then(|v| v.first()) .cloned(); let object_uuid = if let Some(custom_uuid_attr) = &config.uuid_attribute { // Try parsing as a binary UUID search_entry .bin_attrs .get(custom_uuid_attr) .and_then(|v: &Vec>| v.first()) .and_then(|b| Uuid::from_slice(&b[..]) .inspect_err(|e| { warn!("Failed to parse UUID {b:?} from LDAP attribute {custom_uuid_attr}: {e}"); }) .ok()) .or_else(|| { // Try parsing as a string UUID search_entry .attrs .get(custom_uuid_attr) .and_then(|v| v.first()) .and_then(|s| { Uuid::parse_str(s) .inspect_err(|e| { warn!("Failed to parse UUID {s} from LDAP attribute {custom_uuid_attr}: {e}"); }) .ok() }) }) } else { // Default behavior: Active Directory uses objectGUID, OpenLDAP uses entryUUID search_entry .bin_attrs .get("objectGUID") .or_else(|| search_entry.bin_attrs.get("entryUUID")) .and_then(|v: &Vec>| v.first()) .and_then(|b| Uuid::from_slice(&b[..]).ok()) } .ok_or(LdapError::NoUUID(dn.clone()))?; // Extract SSH public keys let ssh_public_keys = search_entry .attrs .get(&config.ssh_key_attribute) .cloned() .unwrap_or_default(); Ok(LdapUser { username, email, display_name, dn, object_uuid, ssh_public_keys, }) } pub async fn list_users(config: &LdapConfig) -> Result> { let mut ldap = connect(config).await?; let mut all_users = Vec::new(); let mut seen_dns = HashSet::new(); // Query each base DN for base_dn in &config.base_dns { debug!("Searching for users in base DN: {}", base_dn); let (rs, _res) = ldap .search( base_dn, Scope::Subtree, &config.user_filter, &ldap_user_attributes(config), ) .await .map_err(|e| LdapError::QueryFailed(format!("Search failed in {}: {}", base_dn, e)))? .success() .map_err(|e| LdapError::QueryFailed(format!("Search failed in {}: {}", base_dn, e)))?; for entry in rs { let search_entry = SearchEntry::construct(entry); let dn = search_entry.dn.clone(); // Skip duplicates (same DN might appear in multiple searches) if seen_dns.contains(&dn) { continue; } seen_dns.insert(dn.clone()); match extract_ldap_user(search_entry, config) { Ok(user) => { all_users.push(user); } Err(e) => { warn!("Skipping LDAP user {dn}: {e}"); continue; } } } } Ok(all_users) } pub async fn find_user_by_username( config: &LdapConfig, username: &str, ) -> Result> { let mut ldap = connect(config).await?; let filter = format!( "(&{}({}={}))", config.user_filter, config.username_attribute.attribute_name(), username ); if let Some(user) = find_user_by_filter(&mut ldap, config, &filter).await? { return Ok(Some(user)); } debug!("No user found with username: {username}"); Ok(None) } async fn find_user_by_filter( ldap: &mut ldap3::Ldap, config: &LdapConfig, filter: &str, ) -> Result> { debug!("Searching LDAP with filter: {filter}"); for base_dn in &config.base_dns { let (rs, _res) = ldap .search( base_dn, Scope::Subtree, filter, vec!["*", "+"], // Request all user attributes (*) and operational attributes (+) ) .await .map_err(|e| LdapError::QueryFailed(e.to_string()))? .success() .map_err(|e| LdapError::QueryFailed(e.to_string()))?; if !rs.is_empty() { #[allow(clippy::unwrap_used, reason = "length checked")] let search_entry = SearchEntry::construct(rs.into_iter().next().unwrap()); match extract_ldap_user(search_entry, config) { Ok(user) => { debug!("Found LDAP user with filter {filter}: {user:?}"); return Ok(Some(user)); } Err(e) => { warn!("LDAP result extraction failed for filter {filter}: {e}"); continue; } } } } Ok(None) } pub async fn find_user_by_uuid( config: &LdapConfig, object_uuid: &Uuid, ) -> Result> { let mut ldap = connect(config).await?; // Convert UUID to different formats for searching // OpenLDAP uses standard UUID string format (with dashes) let uuid_str = object_uuid.to_string(); // Active Directory stores objectGUID as binary and requires hex encoding in filters // Convert UUID bytes to escaped hex string for LDAP filter (e.g., \01\02\03...) let binary_guid_str = { let uuid_bytes = object_uuid.as_bytes(); uuid_bytes.iter().fold(String::new(), |mut s, b| { let _ = write!(&mut s, "\\{:02x}", b); s }) }; let user_filter = &config.user_filter; if let Some(custom_uuid_attr) = &config.uuid_attribute { // Note: the reason for doing multiple separate requests is for `lldap` compatibility // lldap does not support queries with non-UTF8 attribute values and fails if // we try to query with multiple values OR'ed if let Some(user) = find_user_by_filter( &mut ldap, config, &format!("(&{user_filter}({custom_uuid_attr}={uuid_str}))"), ) .await? { return Ok(Some(user)); } // Active Directory if let Some(user) = find_user_by_filter( &mut ldap, config, &format!("(&{user_filter}({custom_uuid_attr}={binary_guid_str}))"), ) .await? { return Ok(Some(user)); } } else { if let Some(user) = find_user_by_filter( &mut ldap, config, // OpenLDAP style &format!("(&{user_filter}(entryUUID={uuid_str}))"), ) .await? { return Ok(Some(user)); } if let Some(user) = find_user_by_filter( &mut ldap, config, &format!("(&{user_filter}(objectGUID={uuid_str}))"), ) .await? { return Ok(Some(user)); } if let Some(user) = find_user_by_filter( &mut ldap, config, // Active Directory &format!("(&{user_filter}(objectGUID={binary_guid_str}))"), ) .await? { return Ok(Some(user)); } } debug!("No user found with UUID: {}", object_uuid); Ok(None) } ================================================ FILE: warpgate-ldap/src/types.rs ================================================ use poem_openapi::Enum; use serde::{Deserialize, Serialize}; use uuid::Uuid; use warpgate_tls::TlsMode; #[derive(Debug, Clone, Copy, Enum)] pub enum LdapUsernameAttribute { Cn, Uid, Email, UserPrincipalName, SamAccountName, } impl LdapUsernameAttribute { pub fn attribute_name(&self) -> &'static str { match self { LdapUsernameAttribute::Cn => "cn", LdapUsernameAttribute::Uid => "uid", LdapUsernameAttribute::Email => "mail", LdapUsernameAttribute::UserPrincipalName => "userPrincipalName", LdapUsernameAttribute::SamAccountName => "sAMAccountName", } } } impl TryFrom<&str> for LdapUsernameAttribute { type Error = (); fn try_from(value: &str) -> Result { Ok(match value { "cn" => Self::Cn, "uid" => Self::Uid, "mail" => Self::Email, "userPrincipalName" => Self::UserPrincipalName, "sAMAccountName" => Self::SamAccountName, _ => return Err(()), }) } } #[derive(Debug, Clone)] pub struct LdapConfig { pub host: String, pub port: u16, pub bind_dn: String, pub bind_password: String, pub tls_mode: TlsMode, pub tls_verify: bool, pub base_dns: Vec, pub user_filter: String, pub username_attribute: LdapUsernameAttribute, pub ssh_key_attribute: String, pub uuid_attribute: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LdapUser { pub username: String, pub email: Option, pub display_name: Option, pub dn: String, pub object_uuid: Uuid, pub ssh_public_keys: Vec, } ================================================ FILE: warpgate-protocol-http/Cargo.toml ================================================ [package] edition = "2021" license = "Apache-2.0" name = "warpgate-protocol-http" version = "0.22.0" [dependencies] anyhow = "1.0" async-trait = "0.1" chrono = { version = "0.4", default-features = false, features = ["serde"] } cookie = "0.18" data-encoding.workspace = true delegate.workspace = true futures.workspace = true http = { version = "1.0", default-features = false } once_cell = { version = "1.17", default-features = false } poem.workspace = true poem-openapi.workspace = true reqwest.workspace = true sea-orm.workspace = true serde.workspace = true serde_json.workspace = true tokio.workspace = true tokio-tungstenite.workspace = true tracing.workspace = true warpgate-admin = { version = "*", path = "../warpgate-admin", default-features = false } warpgate-common = { version = "*", path = "../warpgate-common", default-features = false } warpgate-common-http = { path = "../warpgate-common-http" } warpgate-ca = { version = "*", path = "../warpgate-ca", default-features = false } warpgate-tls = { version = "*", path = "../warpgate-tls", default-features = false } warpgate-core = { version = "*", path = "../warpgate-core", default-features = false } warpgate-db-entities = { version = "*", path = "../warpgate-db-entities", default-features = false } warpgate-web = { version = "*", path = "../warpgate-web", default-features = false } warpgate-sso = { version = "*", path = "../warpgate-sso", default-features = false } percent-encoding = { version = "2.1", default-features = false } uuid.workspace = true regex.workspace = true url = { version = "2.4", default-features = false } ================================================ FILE: warpgate-protocol-http/src/api/api_tokens.rs ================================================ use chrono::{DateTime, Utc}; use poem::web::Data; use poem_openapi::param::Path; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, Object, OpenApi}; use sea_orm::{ActiveModelTrait, ColumnTrait, ModelTrait, QueryFilter, Set}; use uuid::Uuid; use warpgate_common::helpers::hash::generate_ticket_secret; use warpgate_common::WarpgateError; use warpgate_common_http::auth::AuthenticatedRequestContext; use warpgate_db_entities::ApiToken; use super::common::get_user; use crate::common::endpoint_auth; pub struct Api; #[derive(ApiResponse)] enum GetApiTokensResponse { #[oai(status = 200)] Ok(Json>), #[oai(status = 401)] Unauthorized, } #[derive(Object)] struct NewApiToken { label: String, expiry: DateTime, } #[derive(Object)] struct ExistingApiToken { id: Uuid, label: String, created: DateTime, expiry: DateTime, } impl From for ExistingApiToken { fn from(token: ApiToken::Model) -> Self { Self { id: token.id, label: token.label, created: token.created, expiry: token.expiry, } } } #[derive(Object)] struct TokenAndSecret { token: ExistingApiToken, secret: String, } #[derive(ApiResponse)] enum CreateApiTokenResponse { #[oai(status = 201)] Created(Json), #[oai(status = 401)] Unauthorized, } #[derive(ApiResponse)] enum DeleteApiTokenResponse { #[oai(status = 204)] Deleted, #[oai(status = 401)] Unauthorized, #[oai(status = 404)] NotFound, } #[OpenApi] impl Api { #[oai( path = "/profile/api-tokens", method = "get", operation_id = "get_my_api_tokens", transform = "endpoint_auth" )] async fn api_get_api_tokens( &self, ctx: Data<&AuthenticatedRequestContext>, ) -> Result { let auth = &ctx.auth; let db = ctx.services.db.lock().await; let Some(user_model) = get_user(auth, &db).await? else { return Ok(GetApiTokensResponse::Unauthorized); }; let api_tokens = user_model.find_related(ApiToken::Entity).all(&*db).await?; Ok(GetApiTokensResponse::Ok(Json( api_tokens.into_iter().map(Into::into).collect(), ))) } #[oai( path = "/profile/api-tokens", method = "post", operation_id = "create_api_token", transform = "endpoint_auth" )] async fn api_create_api_token( &self, ctx: Data<&AuthenticatedRequestContext>, body: Json, ) -> Result { let auth = &ctx.auth; let db = ctx.services.db.lock().await; let Some(user_model) = get_user(auth, &db).await? else { return Ok(CreateApiTokenResponse::Unauthorized); }; let secret = generate_ticket_secret(); let object = ApiToken::ActiveModel { id: Set(Uuid::new_v4()), user_id: Set(user_model.id), created: Set(Utc::now()), expiry: Set(body.expiry), label: Set(body.label.clone()), secret: Set(secret.expose_secret().to_string()), } .insert(&*db) .await .map_err(WarpgateError::from)?; Ok(CreateApiTokenResponse::Created(Json(TokenAndSecret { token: object.into(), secret: secret.expose_secret().to_string(), }))) } #[oai( path = "/profile/api-tokens/:id", method = "delete", operation_id = "delete_my_api_token", transform = "endpoint_auth" )] async fn api_delete_api_token( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, ) -> Result { let auth = &ctx.auth; let db = ctx.services.db.lock().await; let Some(user_model) = get_user(auth, &db).await? else { return Ok(DeleteApiTokenResponse::Unauthorized); }; let Some(model) = user_model .find_related(ApiToken::Entity) .filter(ApiToken::Column::Id.eq(id.0)) .one(&*db) .await? else { return Ok(DeleteApiTokenResponse::NotFound); }; model.delete(&*db).await?; Ok(DeleteApiTokenResponse::Deleted) } } ================================================ FILE: warpgate-protocol-http/src/api/auth.rs ================================================ use std::ops::DerefMut; use std::sync::Arc; use anyhow::bail; use chrono::{DateTime, Utc}; use futures::{SinkExt, StreamExt}; use poem::session::Session; use poem::web::websocket::{Message, WebSocket}; use poem::web::Data; use poem::{handler, IntoResponse, Request}; use poem_openapi::param::Path; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, Enum, Object, OpenApi}; use tokio::sync::Mutex; use tracing::*; use uuid::Uuid; use warpgate_admin::api::AnySecurityScheme; use warpgate_common::auth::{AuthCredential, AuthResult, AuthState, CredentialKind}; use warpgate_common::{Secret, WarpgateError}; use warpgate_common_http::auth::{AuthenticatedRequestContext, UnauthenticatedRequestContext}; use warpgate_common_http::{RequestAuthorization, SessionAuthorization}; use warpgate_core::{ConfigProvider, Services}; use super::common::logout; use crate::common::{authorize_session, endpoint_auth, get_auth_state_for_request, SessionExt}; use crate::session::SessionStore; pub struct Api; #[derive(Object)] struct LoginRequest { username: String, password: String, } #[derive(Object)] struct OtpLoginRequest { otp: String, } #[derive(Enum)] enum ApiAuthState { NotStarted, Failed, PasswordNeeded, OtpNeeded, SsoNeeded, WebUserApprovalNeeded, PublicKeyNeeded, Success, } #[derive(Object)] struct LoginFailureResponse { state: ApiAuthState, } #[derive(ApiResponse)] enum LoginResponse { #[oai(status = 201)] Success, #[oai(status = 401)] Failure(Json), } #[derive(ApiResponse)] enum LogoutResponse { #[oai(status = 201)] Success, } #[derive(Object)] struct AuthStateResponseInternal { pub id: String, pub protocol: String, pub address: Option, pub started: DateTime, pub state: ApiAuthState, pub identification_string: String, } #[derive(ApiResponse)] enum AuthStateListResponse { #[oai(status = 200)] Ok(Json>), #[oai(status = 404)] NotFound, } #[derive(ApiResponse)] enum AuthStateResponse { #[oai(status = 200)] Ok(Json), #[oai(status = 404)] NotFound, } const PREFERRED_NEED_CRED_ORDER: &[CredentialKind] = &[ CredentialKind::PublicKey, CredentialKind::Password, CredentialKind::Totp, CredentialKind::Sso, CredentialKind::WebUserApproval, ]; impl From for ApiAuthState { fn from(state: AuthResult) -> Self { match state { AuthResult::Rejected => ApiAuthState::Failed, AuthResult::Need(kinds) => { let kind = PREFERRED_NEED_CRED_ORDER .iter() .find(|x| kinds.contains(x)) .or(kinds.iter().next()); match kind { Some(CredentialKind::Password) => ApiAuthState::PasswordNeeded, Some(CredentialKind::Totp) => ApiAuthState::OtpNeeded, Some(CredentialKind::Sso) => ApiAuthState::SsoNeeded, Some(CredentialKind::WebUserApproval) => ApiAuthState::WebUserApprovalNeeded, Some(CredentialKind::PublicKey) => ApiAuthState::PublicKeyNeeded, Some(CredentialKind::Certificate) => { // Certificate authentication is not supported for HTTP protocol // This credential type is primarily for Kubernetes ApiAuthState::Failed } None => ApiAuthState::Failed, } } AuthResult::Accepted { .. } => ApiAuthState::Success, } } } #[OpenApi] impl Api { #[oai(path = "/auth/login", method = "post", operation_id = "login")] async fn api_auth_login( &self, req: &Request, session: &Session, ctx: Data<&UnauthenticatedRequestContext>, body: Json, ) -> poem::Result { let services = &ctx.services; let mut auth_state_store = services.auth_state_store.lock().await; let state_arc = match get_auth_state_for_request( &body.username, session, &mut auth_state_store, ) .await { Err(WarpgateError::UserNotFound(_)) => { return Ok(LoginResponse::Failure(Json(LoginFailureResponse { state: ApiAuthState::Failed, }))) } x => x, }?; let mut state = state_arc.lock().await; let mut cp = services.config_provider.lock().await; let password_cred = AuthCredential::Password(Secret::new(body.password.clone())); if cp .validate_credential(&state.user_info().username, &password_cred) .await? { state.add_valid_credential(password_cred); } match state.verify() { AuthResult::Accepted { user_info } => { auth_state_store.complete(state.id()).await; authorize_session(req, &ctx, user_info).await?; Ok(LoginResponse::Success) } x => { error!("Auth rejected"); Ok(LoginResponse::Failure(Json(LoginFailureResponse { state: x.into(), }))) } } } #[oai(path = "/auth/otp", method = "post", operation_id = "otpLogin")] async fn api_auth_otp_login( &self, req: &Request, session: &Session, ctx: Data<&UnauthenticatedRequestContext>, body: Json, ) -> poem::Result { let services = &ctx.services; let state_id = session.get_auth_state_id(); let mut auth_state_store = services.auth_state_store.lock().await; let Some(state_arc) = state_id.and_then(|id| auth_state_store.get(&id.0)) else { return Ok(LoginResponse::Failure(Json(LoginFailureResponse { state: ApiAuthState::NotStarted, }))); }; let mut state = state_arc.lock().await; let mut cp = services.config_provider.lock().await; let otp_cred = AuthCredential::Otp(body.otp.clone().into()); if cp .validate_credential(&state.user_info().username, &otp_cred) .await? { state.add_valid_credential(otp_cred); } else { warn!("Invalid OTP for user {}", state.user_info().username); } match state.verify() { AuthResult::Accepted { user_info } => { auth_state_store.complete(state.id()).await; authorize_session(req, &ctx, user_info).await?; Ok(LoginResponse::Success) } x => Ok(LoginResponse::Failure(Json(LoginFailureResponse { state: x.into(), }))), } } #[oai(path = "/auth/logout", method = "post", operation_id = "logout")] async fn api_auth_logout( &self, session: &Session, session_middleware: Data<&Arc>>, ) -> poem::Result { logout(session, session_middleware.lock().await.deref_mut()); Ok(LogoutResponse::Success) } #[oai( path = "/auth/state", method = "get", operation_id = "get_default_auth_state" )] async fn api_default_auth_state( &self, session: &Session, ctx: Data<&UnauthenticatedRequestContext>, ) -> poem::Result { let services = &ctx.services; let Some(state_id) = session.get_auth_state_id() else { return Ok(AuthStateResponse::NotFound); }; let store = services.auth_state_store.lock().await; let Some(state_arc) = store.get(&state_id.0) else { return Ok(AuthStateResponse::NotFound); }; serialize_auth_state_inner(state_arc, services) .await .map(Json) .map(AuthStateResponse::Ok) } #[oai( path = "/auth/state", method = "delete", operation_id = "cancel_default_auth" )] async fn api_cancel_default_auth( &self, session: &Session, ctx: Data<&UnauthenticatedRequestContext>, ) -> poem::Result { let services = &ctx.services; let Some(state_id) = session.get_auth_state_id() else { return Ok(AuthStateResponse::NotFound); }; let mut store = services.auth_state_store.lock().await; let Some(state_arc) = store.get(&state_id.0) else { return Ok(AuthStateResponse::NotFound); }; state_arc.lock().await.reject(); store.complete(&state_id.0).await; session.clear_auth_state(); serialize_auth_state_inner(state_arc, services) .await .map(Json) .map(AuthStateResponse::Ok) } #[oai( path = "/auth/web-auth-requests", method = "get", operation_id = "get_web_auth_requests", transform = "endpoint_auth" )] async fn get_web_auth_requests( &self, ctx: Data<&AuthenticatedRequestContext>, _sec_scheme: AnySecurityScheme, ) -> poem::Result { let services = &ctx.services; let store = services.auth_state_store.lock().await; let RequestAuthorization::Session(SessionAuthorization::User(ref username)) = ctx.auth else { return Ok(AuthStateListResponse::NotFound); }; let state_arcs = store.all_pending_web_auths_for_user(username).await; let mut results = vec![]; for state_arc in state_arcs { results.push(serialize_auth_state_inner(state_arc, services).await?) } Ok(AuthStateListResponse::Ok(Json(results))) } #[oai( path = "/auth/state/:id", method = "get", operation_id = "get_auth_state", transform = "endpoint_auth" )] async fn api_auth_state( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, ) -> poem::Result { let services = &ctx.services; let state_arc = get_auth_state(&id, &ctx).await; let Some(state_arc) = state_arc else { return Ok(AuthStateResponse::NotFound); }; serialize_auth_state_inner(state_arc, services) .await .map(Json) .map(AuthStateResponse::Ok) } #[oai( path = "/auth/state/:id/approve", method = "post", operation_id = "approve_auth", transform = "endpoint_auth" )] async fn api_approve_auth( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> poem::Result { let services = &ctx.services; let Some(state_arc) = get_auth_state(&id, &ctx).await else { return Ok(AuthStateResponse::NotFound); }; let auth_result = { let mut state = state_arc.lock().await; state.add_valid_credential(AuthCredential::WebUserApproval); state.verify() }; if let AuthResult::Accepted { .. } = auth_result { let mut store = services.auth_state_store.lock().await; store.complete(&id).await; } serialize_auth_state_inner(state_arc, services) .await .map(Json) .map(AuthStateResponse::Ok) } #[oai( path = "/auth/state/:id/reject", method = "post", operation_id = "reject_auth", transform = "endpoint_auth" )] async fn api_reject_auth( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> poem::Result { let services = &ctx.services; let Some(state_arc) = get_auth_state(&id, &ctx).await else { return Ok(AuthStateResponse::NotFound); }; state_arc.lock().await.reject(); services.auth_state_store.lock().await.complete(&id).await; serialize_auth_state_inner(state_arc, services) .await .map(Json) .map(AuthStateResponse::Ok) } } async fn get_auth_state( id: &Uuid, ctx: &AuthenticatedRequestContext, ) -> Option>> { let store = ctx.services.auth_state_store.lock().await; let RequestAuthorization::Session(SessionAuthorization::User(username)) = &ctx.auth else { return None; }; let state_arc = store.get(id)?; { let state = state_arc.lock().await; if &state.user_info().username != username { return None; } } Some(state_arc) } async fn serialize_auth_state_inner( state_arc: Arc>, services: &Services, ) -> poem::Result { let state = state_arc.lock().await; let session_state_store = services.state.lock().await; let session_state = state .session_id() .and_then(|session_id| session_state_store.sessions.get(&session_id)); let peer_addr = match session_state { Some(x) => x.lock().await.remote_address, None => None, }; Ok(AuthStateResponseInternal { id: state.id().to_string(), protocol: state.protocol().to_string(), address: peer_addr.map(|x| x.ip().to_string()), started: *state.started(), state: state.verify().into(), identification_string: state.identification_string().to_owned(), }) } #[handler] pub async fn api_get_web_auth_requests_stream( ws: WebSocket, ctx: Data<&AuthenticatedRequestContext>, ) -> anyhow::Result { let services = &ctx.services; let auth_state_store = services.auth_state_store.clone(); let username = match ctx.auth { RequestAuthorization::Session(SessionAuthorization::User(ref username)) => username.clone(), _ => bail!("Only session-based user auth is supported for this endpoint"), }; let mut rx = { let mut s = auth_state_store.lock().await; s.subscribe_web_auth_request() }; Ok(ws.on_upgrade(|socket| async move { let (mut sink, _) = socket.split(); while let Ok(id) = rx.recv().await { let auth_state_store = auth_state_store.lock().await; if let Some(state) = auth_state_store.get(&id) { let state = state.lock().await; if state.user_info().username == username { sink.send(Message::Text(id.to_string())).await?; } } } Ok::<(), anyhow::Error>(()) })) } ================================================ FILE: warpgate-protocol-http/src/api/common.rs ================================================ use poem::session::Session; use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter}; use tracing::info; use warpgate_common::WarpgateError; use warpgate_common_http::RequestAuthorization; use warpgate_db_entities as entities; use crate::session::SessionStore; pub fn logout(session: &Session, session_middleware: &mut SessionStore) { session_middleware.remove_session(session); session.clear(); info!("Logged out"); } pub async fn get_user( auth: &RequestAuthorization, db: &DatabaseConnection, ) -> Result, WarpgateError> { let Some(username) = auth.username() else { return Ok(None); }; let Some(user_model) = entities::User::Entity::find() .filter(entities::User::Column::Username.eq(username)) .one(db) .await? else { return Ok(None); }; Ok(Some(user_model)) } ================================================ FILE: warpgate-protocol-http/src/api/credentials.rs ================================================ use chrono::{DateTime, Utc}; use http::StatusCode; use poem::web::Data; use poem::{Endpoint, EndpointExt, FromRequest, IntoResponse}; use poem_openapi::param::Path; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, Enum, Object, OpenApi}; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, QueryFilter, Set}; use uuid::Uuid; use warpgate_common::{User, UserPasswordCredential, UserRequireCredentialsPolicy, WarpgateError}; use warpgate_common_http::auth::{AuthenticatedRequestContext, UnauthenticatedRequestContext}; use warpgate_db_entities::{ self as entities, CertificateCredential, Parameters, PasswordCredential, PublicKeyCredential, }; use super::common::get_user; use crate::api::AnySecurityScheme; use crate::common::endpoint_auth; pub struct Api; #[derive(Enum)] enum PasswordState { Unset, Set, MultipleSet, } #[derive(Object)] struct ExistingSsoCredential { id: Uuid, provider: Option, email: String, } impl From for ExistingSsoCredential { fn from(credential: entities::SsoCredential::Model) -> Self { Self { id: credential.id, provider: credential.provider, email: credential.email, } } } #[derive(Object)] struct ChangePasswordRequest { password: String, } #[derive(ApiResponse)] enum ChangePasswordResponse { #[oai(status = 201)] Done(Json), #[oai(status = 401)] Unauthorized, } #[derive(Object)] pub struct CredentialsState { password: PasswordState, otp: Vec, public_keys: Vec, certificates: Vec, sso: Vec, credential_policy: UserRequireCredentialsPolicy, ldap_linked: bool, } #[derive(ApiResponse)] #[allow(clippy::large_enum_variant)] enum CredentialsStateResponse { #[oai(status = 200)] Ok(Json), #[oai(status = 401)] Unauthorized, } #[derive(Object)] struct NewPublicKeyCredential { label: String, openssh_public_key: String, } #[derive(Object)] struct ExistingPublicKeyCredential { id: Uuid, label: String, date_added: Option>, last_used: Option>, abbreviated: String, } fn abbreviate_public_key(k: &str) -> String { let l = 10; if k.len() <= l { return k.to_string(); // Return the full key if it's shorter than or equal to `l`. } format!( "{}...{}", &k[..l.min(k.len())], // Take the first `l` characters. &k[k.len().saturating_sub(l)..] // Take the last `l` characters safely. ) } impl From for ExistingPublicKeyCredential { fn from(credential: entities::PublicKeyCredential::Model) -> Self { Self { id: credential.id, label: credential.label, date_added: credential.date_added, last_used: credential.last_used, abbreviated: abbreviate_public_key(&credential.openssh_public_key), } } } #[derive(ApiResponse)] enum CreatePublicKeyCredentialResponse { #[oai(status = 201)] Created(Json), #[oai(status = 401)] Unauthorized, } #[derive(ApiResponse)] enum DeleteCredentialResponse { #[oai(status = 204)] Deleted, #[oai(status = 401)] Unauthorized, #[oai(status = 404)] NotFound, } #[derive(Object)] struct NewOtpCredential { secret_key: Vec, } #[derive(Object)] struct ExistingOtpCredential { id: Uuid, } impl From for ExistingOtpCredential { fn from(credential: entities::OtpCredential::Model) -> Self { Self { id: credential.id } } } #[derive(ApiResponse)] enum CreateOtpCredentialResponse { #[oai(status = 201)] Created(Json), #[oai(status = 401)] Unauthorized, } #[derive(Object)] struct ExistingCertificateCredential { id: Uuid, label: String, date_added: Option>, last_used: Option>, fingerprint: String, } fn certificate_fingerprint(certificate_pem: &str) -> Result { Ok(warpgate_ca::certificate_sha256_hex_fingerprint( &warpgate_ca::deserialize_certificate(certificate_pem)?, )?) } impl From for ExistingCertificateCredential { fn from(credential: entities::CertificateCredential::Model) -> Self { Self { id: credential.id, label: credential.label, date_added: credential.date_added, last_used: credential.last_used, fingerprint: certificate_fingerprint(&credential.certificate_pem) .unwrap_or_else(|_| "Invalid certificate".into()), } } } #[derive(Object)] struct IssuedCertificateCredential { credential: ExistingCertificateCredential, certificate_pem: String, } #[derive(Object)] struct IssueCertificateCredentialRequest { label: String, public_key_pem: String, } #[derive(ApiResponse)] enum IssueCertificateCredentialResponse { #[oai(status = 201)] Issued(Json), #[oai(status = 401)] Unauthorized, } #[derive(ApiResponse)] enum DeleteCertificateCredentialResponse { #[oai(status = 200)] Ok, #[oai(status = 401)] Unauthorized, #[oai(status = 404)] NotFound, } pub fn parameters_based_auth(e: E) -> impl Endpoint { e.around(|ep, req| async move { let ctx = Data::<&UnauthenticatedRequestContext>::from_request_without_body(&req).await?; let services = &ctx.services; let parameters = Parameters::Entity::get(&*services.db.lock().await) .await .map_err(WarpgateError::from)?; if !parameters.allow_own_credential_management { return Ok(poem::Response::builder() .status(StatusCode::FORBIDDEN) .body("Credential management is disabled") .into_response()); } Ok(endpoint_auth(ep).call(req).await?.into_response()) }) } #[OpenApi] impl Api { #[oai( path = "/profile/credentials", method = "get", operation_id = "get_my_credentials", transform = "parameters_based_auth" )] async fn api_get_credentials_state( &self, ctx: Data<&AuthenticatedRequestContext>, _sec_scheme: AnySecurityScheme, ) -> Result { let auth = &ctx.auth; let db = ctx.services.db.lock().await; let Some(user_model) = get_user(auth, &db).await? else { return Ok(CredentialsStateResponse::Unauthorized); }; let user = User::try_from(user_model.clone())?; let otp_creds = user_model .find_related(entities::OtpCredential::Entity) .all(&*db) .await?; let password_creds = user_model .find_related(entities::PasswordCredential::Entity) .all(&*db) .await?; let sso_creds = user_model .find_related(entities::SsoCredential::Entity) .all(&*db) .await?; let pk_creds = user_model .find_related(entities::PublicKeyCredential::Entity) .all(&*db) .await?; let cert_creds = user_model .find_related(entities::CertificateCredential::Entity) .all(&*db) .await?; Ok(CredentialsStateResponse::Ok(Json(CredentialsState { password: match password_creds.len() { 0 => PasswordState::Unset, 1 => PasswordState::Set, _ => PasswordState::MultipleSet, }, otp: otp_creds.into_iter().map(Into::into).collect(), public_keys: pk_creds.into_iter().map(Into::into).collect(), certificates: cert_creds.into_iter().map(Into::into).collect(), sso: sso_creds.into_iter().map(Into::into).collect(), credential_policy: user.credential_policy.unwrap_or_default(), ldap_linked: user_model.ldap_server_id.is_some(), }))) } #[oai( path = "/profile/credentials/password", method = "post", operation_id = "change_my_password", transform = "parameters_based_auth" )] async fn api_change_password( &self, ctx: Data<&AuthenticatedRequestContext>, body: Json, _sec_scheme: AnySecurityScheme, ) -> Result { let auth = &ctx.auth; let db = ctx.services.db.lock().await; let Some(user_model) = get_user(auth, &db).await? else { return Ok(ChangePasswordResponse::Unauthorized); }; entities::PasswordCredential::Entity::delete_many() .filter(entities::PasswordCredential::Column::UserId.eq(user_model.id)) .exec(&*db) .await .map_err(WarpgateError::from)?; let new_credential = entities::PasswordCredential::ActiveModel { id: Set(Uuid::new_v4()), user_id: Set(user_model.id), ..PasswordCredential::ActiveModel::from(UserPasswordCredential::from_password( &body.password.clone().into(), )) } .insert(&*db) .await .map_err(WarpgateError::from)?; entities::PasswordCredential::Entity::find() .filter( entities::PasswordCredential::Column::UserId .eq(user_model.id) .and(entities::PasswordCredential::Column::Id.ne(new_credential.id)), ) .all(&*db) .await?; Ok(ChangePasswordResponse::Done(Json(PasswordState::Set))) } #[oai( path = "/profile/credentials/public-keys", method = "post", operation_id = "add_my_public_key", transform = "parameters_based_auth" )] async fn api_create_pk( &self, ctx: Data<&AuthenticatedRequestContext>, body: Json, _sec_scheme: AnySecurityScheme, ) -> Result { let auth = &ctx.auth; let db = ctx.services.db.lock().await; let Some(user_model) = get_user(auth, &db).await? else { return Ok(CreatePublicKeyCredentialResponse::Unauthorized); }; let object = PublicKeyCredential::ActiveModel { id: Set(Uuid::new_v4()), user_id: Set(user_model.id), date_added: Set(Some(Utc::now())), last_used: Set(None), label: Set(body.label.clone()), openssh_public_key: Set(body.openssh_public_key.clone()), } .insert(&*db) .await .map_err(WarpgateError::from)?; Ok(CreatePublicKeyCredentialResponse::Created(Json( object.into(), ))) } #[oai( path = "/profile/credentials/public-keys/:id", method = "delete", operation_id = "delete_my_public_key", transform = "parameters_based_auth" )] async fn api_delete_pk( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { let auth = &ctx.auth; let db = ctx.services.db.lock().await; let Some(user_model) = get_user(auth, &db).await? else { return Ok(DeleteCredentialResponse::Unauthorized); }; let Some(model) = user_model .find_related(entities::PublicKeyCredential::Entity) .filter(entities::PublicKeyCredential::Column::Id.eq(id.0)) .one(&*db) .await? else { return Ok(DeleteCredentialResponse::NotFound); }; model.delete(&*db).await?; Ok(DeleteCredentialResponse::Deleted) } #[oai( path = "/profile/credentials/otp", method = "post", operation_id = "add_my_otp", transform = "parameters_based_auth" )] async fn api_create_otp( &self, ctx: Data<&AuthenticatedRequestContext>, body: Json, _sec_scheme: AnySecurityScheme, ) -> Result { let auth = &ctx.auth; let db = ctx.services.db.lock().await; let Some(user_model) = get_user(auth, &db).await? else { return Ok(CreateOtpCredentialResponse::Unauthorized); }; let mut user: User = user_model.clone().try_into()?; let object = entities::OtpCredential::ActiveModel { id: Set(Uuid::new_v4()), user_id: Set(user_model.id), secret_key: Set(body.secret_key.clone()), } .insert(&*db) .await .map_err(WarpgateError::from)?; let details = user_model.load_details(&db).await?; user.credential_policy = Some( user.credential_policy .unwrap_or_default() .upgrade_to_otp(details.credentials.as_slice()), ); entities::User::ActiveModel::try_from(user)? .update(&*db) .await?; Ok(CreateOtpCredentialResponse::Created(Json(object.into()))) } #[oai( path = "/profile/credentials/otp/:id", method = "delete", operation_id = "delete_my_otp", transform = "parameters_based_auth" )] async fn api_delete_otp( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, _sec_scheme: AnySecurityScheme, ) -> Result { let auth = &ctx.auth; let db = ctx.services.db.lock().await; let Some(user_model) = get_user(auth, &db).await? else { return Ok(DeleteCredentialResponse::Unauthorized); }; let Some(model) = user_model .find_related(entities::OtpCredential::Entity) .filter(entities::OtpCredential::Column::Id.eq(id.0)) .one(&*db) .await? else { return Ok(DeleteCredentialResponse::NotFound); }; model.delete(&*db).await?; Ok(DeleteCredentialResponse::Deleted) } #[oai( path = "/profile/credentials/certificates", method = "post", operation_id = "issue_my_certificate", transform = "parameters_based_auth" )] async fn api_issue_certificate( &self, ctx: Data<&AuthenticatedRequestContext>, body: Json, ) -> Result { let auth = &ctx.auth; let db = ctx.services.db.lock().await; let Some(user_model) = get_user(auth, &db).await? else { return Ok(IssueCertificateCredentialResponse::Unauthorized); }; // Fetch CA params let params = Parameters::Entity::get(&db).await?; let ca = warpgate_ca::deserialize_ca(¶ms.ca_certificate_pem, ¶ms.ca_private_key_pem)?; let public_key_pem = body.public_key_pem.trim(); let client_cert = warpgate_ca::issue_client_certificate( &ca, &user_model.username, public_key_pem, user_model.id, )?; let client_cert_pem = warpgate_ca::certificate_to_pem(&client_cert)?; let object = CertificateCredential::ActiveModel { id: Set(Uuid::new_v4()), user_id: Set(user_model.id), date_added: Set(Some(Utc::now())), last_used: Set(None), label: Set(body.label.clone()), certificate_pem: Set(client_cert_pem.clone()), } .insert(&*db) .await .map_err(WarpgateError::from)?; Ok(IssueCertificateCredentialResponse::Issued(Json( IssuedCertificateCredential { credential: object.clone().into(), certificate_pem: client_cert_pem, }, ))) } #[oai( path = "/profile/credentials/certificates/:id", method = "delete", operation_id = "revoke_my_certificate", transform = "parameters_based_auth" )] async fn api_revoke_certificate( &self, ctx: Data<&AuthenticatedRequestContext>, id: Path, ) -> Result { let auth = &ctx.auth; let db = ctx.services.db.lock().await; let Some(user_model) = get_user(auth, &db).await? else { return Ok(DeleteCertificateCredentialResponse::Unauthorized); }; let Some(model) = user_model .find_related(entities::CertificateCredential::Entity) .filter(entities::CertificateCredential::Column::Id.eq(id.0)) .one(&*db) .await? else { return Ok(DeleteCertificateCredentialResponse::NotFound); }; // Add to revocation list let cert = warpgate_ca::deserialize_certificate(&model.certificate_pem)?; entities::CertificateRevocation::ActiveModel { id: Set(Uuid::new_v4()), date_added: Set(Utc::now()), serial_number_base64: Set(warpgate_ca::serialize_certificate_serial(&cert)), } .insert(&*db) .await?; model.delete(&*db).await?; Ok(DeleteCertificateCredentialResponse::Ok) } } ================================================ FILE: warpgate-protocol-http/src/api/info.rs ================================================ use anyhow::Context; use poem::session::Session; use poem::web::Data; use poem::Request; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, Object, OpenApi}; use sea_orm::{ColumnTrait, EntityTrait, ModelTrait, QueryFilter}; use serde::Serialize; use warpgate_common::version::warpgate_version; use warpgate_common_http::auth::UnauthenticatedRequestContext; use warpgate_common_http::{AuthenticatedRequestContext, SessionAuthorization}; use warpgate_core::ConfigProvider; use warpgate_db_entities::{AdminRole, LdapServer, Parameters, User}; use crate::common::{is_user_admin, SessionExt}; pub struct Api; #[derive(Serialize, Object)] pub struct PortsInfo { ssh: Option, http: Option, mysql: Option, postgres: Option, kubernetes: Option, } #[derive(Serialize, Object, Debug)] pub struct SetupState { has_targets: bool, has_users: bool, } impl SetupState { pub fn completed(&self) -> bool { self.has_targets && self.has_users } } #[derive(Serialize, Object, Default)] pub struct AdminPermissions { targets_create: bool, targets_edit: bool, targets_delete: bool, users_create: bool, users_edit: bool, users_delete: bool, access_roles_create: bool, access_roles_edit: bool, access_roles_delete: bool, access_roles_assign: bool, sessions_view: bool, sessions_terminate: bool, recordings_view: bool, tickets_create: bool, tickets_delete: bool, config_edit: bool, admin_roles_manage: bool, } #[derive(Serialize, Object)] pub struct Info { version: Option, username: Option, selected_target: Option, external_host: Option, ports: PortsInfo, minimize_password_login: bool, authorized_via_ticket: bool, authorized_via_sso_with_single_logout: bool, own_credential_management_allowed: bool, has_ldap: bool, setup_state: Option, admin_permissions: Option, } #[derive(ApiResponse)] enum InstanceInfoResponse { #[oai(status = 200)] Ok(Json), } #[OpenApi] impl Api { #[oai(path = "/info", method = "get", operation_id = "get_info")] async fn api_get_info( &self, req: &Request, session: &Session, ctx: Data<&UnauthenticatedRequestContext>, auth_ctx: Option>, ) -> poem::Result { let config = ctx.services.config.lock().await; let external_host = config .construct_external_url(Some(req), None) .ok() .as_ref() .and_then(|x| x.host()) .map(|x| x.to_string()); let parameters = { Parameters::Entity::get(&*ctx.services.db.lock().await) .await .context("loading parameters")? }; let setup_state = { let (users, targets) = { let mut p = ctx.services.config_provider.lock().await; let users = p.list_users().await?; let targets = p.list_targets().await?; (users, targets) }; let user_is_admin = if let Some(ctx) = &auth_ctx { is_user_admin(ctx).await? } else { false }; if user_is_admin { let state = SetupState { has_targets: targets.len() > 1, has_users: users.len() > 1, }; if !state.completed() { Some(state) } else { None } } else { None } }; let has_ldap = LdapServer::Entity::find() .one(&*ctx.services.db.lock().await) .await .context("loading LDAP servers")? .is_some(); // compute admin permissions (only if authenticated) let admin_permissions = if let Some(ctx) = &auth_ctx { if let Some(username) = ctx.auth.username() { let db = ctx.services.db.lock().await; let perms = { let mut combined = AdminPermissions::default(); if let Some(user) = User::Entity::find() .filter(User::Column::Username.eq(username)) .one(&*db) .await .context("loading user")? { let roles: Vec = user .find_related(AdminRole::Entity) .all(&*db) .await .context("loading roles")?; for r in roles { combined.targets_create |= r.targets_create; combined.targets_edit |= r.targets_edit; combined.targets_delete |= r.targets_delete; combined.users_create |= r.users_create; combined.users_edit |= r.users_edit; combined.users_delete |= r.users_delete; combined.access_roles_create |= r.access_roles_create; combined.access_roles_edit |= r.access_roles_edit; combined.access_roles_delete |= r.access_roles_delete; combined.access_roles_assign |= r.access_roles_assign; combined.sessions_view |= r.sessions_view; combined.sessions_terminate |= r.sessions_terminate; combined.recordings_view |= r.recordings_view; combined.config_edit |= r.config_edit; combined.admin_roles_manage |= r.admin_roles_manage; } } combined }; Some(perms) } else { None } } else { None }; Ok(InstanceInfoResponse::Ok(Json(Info { version: auth_ctx.is_some().then(|| warpgate_version().to_string()), username: session.get_username(), selected_target: session.get_target_name(), external_host, minimize_password_login: parameters.minimize_password_login, authorized_via_ticket: matches!( session.get_auth(), Some(SessionAuthorization::Ticket { .. }) ), authorized_via_sso_with_single_logout: session .get_sso_login_state() .is_some_and(|state| state.supports_single_logout), ports: if auth_ctx.is_some() { PortsInfo { ssh: if config.store.ssh.enable { Some(config.store.ssh.external_port()) } else { None }, http: Some(config.store.http.external_port()), mysql: if config.store.mysql.enable { Some(config.store.mysql.external_port()) } else { None }, postgres: if config.store.postgres.enable { Some(config.store.postgres.external_port()) } else { None }, kubernetes: if config.store.kubernetes.enable { Some(config.store.kubernetes.external_port()) } else { None }, } } else { PortsInfo { ssh: None, http: None, mysql: None, postgres: None, kubernetes: None, } }, own_credential_management_allowed: parameters.allow_own_credential_management, setup_state, has_ldap: auth_ctx.is_some() && has_ldap, admin_permissions, }))) } } ================================================ FILE: warpgate-protocol-http/src/api/mod.rs ================================================ use poem_openapi::OpenApi; mod api_tokens; pub mod auth; mod common; mod credentials; pub mod info; pub mod sso_provider_detail; pub mod sso_provider_list; pub mod targets_list; pub use warpgate_common::api::AnySecurityScheme; pub fn get() -> impl OpenApi { ( auth::Api, info::Api, targets_list::Api, sso_provider_list::Api, sso_provider_detail::Api, credentials::Api, api_tokens::Api, ) } ================================================ FILE: warpgate-protocol-http/src/api/sso_provider_detail.rs ================================================ use poem::session::Session; use poem::web::Data; use poem::Request; use poem_openapi::param::{Path, Query}; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, Object, OpenApi}; use serde::{Deserialize, Serialize}; use tracing::*; use warpgate_common::WarpgateError; use warpgate_common_http::auth::UnauthenticatedRequestContext; use warpgate_sso::{SsoClient, SsoLoginRequest}; pub struct Api; #[derive(Object)] struct StartSsoResponseParams { url: String, } #[allow(clippy::large_enum_variant)] #[derive(ApiResponse)] enum StartSsoResponse { #[oai(status = 200)] Ok(Json), #[oai(status = 404)] NotFound, } pub static SSO_CONTEXT_SESSION_KEY: &str = "sso_request"; #[derive(Debug, Serialize, Deserialize)] pub struct SsoContext { pub provider: String, pub request: SsoLoginRequest, pub next_url: Option, pub supports_single_logout: bool, pub return_host: Option, } #[OpenApi] impl Api { #[oai( path = "/sso/providers/:name/start", method = "get", operation_id = "start_sso" )] async fn api_start_sso( &self, req: &Request, session: &Session, ctx: Data<&UnauthenticatedRequestContext>, name: Path, next: Query>, ) -> Result { let config = ctx.services.config.lock().await; let name = name.0; let Some(provider_config) = config.store.sso_providers.iter().find(|p| p.name == *name) else { return Ok(StartSsoResponse::NotFound); }; let mut return_url = config.construct_external_url( Some(req), provider_config.return_domain_whitelist.as_deref(), )?; info!("{:?}", provider_config); return_url.set_path(&format!( "{}warpgate/api/sso/return", provider_config.return_url_prefix )); debug!("Return URL: {return_url}"); let client = SsoClient::new(provider_config.provider.clone())?; let sso_req = client.start_login(return_url.to_string()).await?; let return_host = req.header("host").map(|h| h.to_string()); let url = sso_req.auth_url().to_string(); session.set( SSO_CONTEXT_SESSION_KEY, SsoContext { provider: name, request: sso_req, next_url: next.0.clone(), supports_single_logout: client.supports_single_logout().await?, return_host, }, ); Ok(StartSsoResponse::Ok(Json(StartSsoResponseParams { url }))) } } ================================================ FILE: warpgate-protocol-http/src/api/sso_provider_list.rs ================================================ use std::ops::DerefMut; use std::sync::Arc; use poem::session::Session; use poem::web::{Data, Form}; use poem::Request; use poem_openapi::param::Query; use poem_openapi::payload::{Html, Json, Response}; use poem_openapi::{ApiResponse, Enum, Object, OpenApi}; use serde::Deserialize; use tokio::sync::Mutex; use tracing::*; use warpgate_common::auth::{AuthCredential, AuthResult}; use warpgate_common::WarpgateError; use warpgate_common_http::auth::UnauthenticatedRequestContext; use warpgate_core::ConfigProvider; use warpgate_sso::{SsoClient, SsoInternalProviderConfig}; use super::sso_provider_detail::{SsoContext, SSO_CONTEXT_SESSION_KEY}; use crate::api::common::logout; use crate::common::{authorize_session, get_auth_state_for_request, SessionExt}; use crate::session::SessionStore; use crate::SsoLoginState; pub struct Api; #[derive(Enum)] pub enum SsoProviderKind { Google, Apple, Azure, Custom, } #[derive(Object)] pub struct SsoProviderDescription { pub name: String, pub label: String, pub kind: SsoProviderKind, } #[derive(ApiResponse)] enum GetSsoProvidersResponse { #[oai(status = 200)] Ok(Json>), } #[allow(clippy::large_enum_variant)] #[derive(ApiResponse)] enum ReturnToSsoResponse { #[oai(status = 307)] Ok, } #[allow(clippy::large_enum_variant)] #[derive(ApiResponse)] enum ReturnToSsoPostResponse { #[oai(status = 200)] Redirect(Html), } #[derive(Deserialize)] pub struct ReturnToSsoFormData { pub code: Option, } #[derive(Object)] struct StartSloResponseParams { url: String, } #[allow(clippy::large_enum_variant)] #[derive(ApiResponse)] enum StartSloResponse { #[oai(status = 200)] Ok(Json), #[oai(status = 400)] NotInSsoSession, #[oai(status = 404)] NotFound, } fn make_redirect_url(err: &str) -> String { error!("SSO error: {err}"); format!("/@warpgate?login_error={err}") } #[OpenApi] impl Api { #[oai( path = "/sso/providers", method = "get", operation_id = "get_sso_providers" )] async fn api_get_all_sso_providers( &self, ctx: Data<&UnauthenticatedRequestContext>, ) -> Result { let mut providers = ctx.services.config.lock().await.store.sso_providers.clone(); providers.sort_by(|a, b| a.label().cmp(b.label())); Ok(GetSsoProvidersResponse::Ok(Json( providers .into_iter() .map(|p| SsoProviderDescription { name: p.name.clone(), label: p.label().to_string(), kind: match p.provider { SsoInternalProviderConfig::Google { .. } => SsoProviderKind::Google, SsoInternalProviderConfig::Apple { .. } => SsoProviderKind::Apple, SsoInternalProviderConfig::Azure { .. } => SsoProviderKind::Azure, SsoInternalProviderConfig::Custom { .. } => SsoProviderKind::Custom, }, }) .collect(), ))) } #[oai(path = "/sso/return", method = "get", operation_id = "return_to_sso")] async fn api_return_to_sso_get( &self, req: &Request, session: &Session, ctx: Data<&UnauthenticatedRequestContext>, code: Query>, ) -> Result, WarpgateError> { let url = self .api_return_to_sso_get_common(req, session, ctx, &code) .await? .unwrap_or_else(|x| make_redirect_url(&x)); Ok(Response::new(ReturnToSsoResponse::Ok).header("Location", url)) } #[oai( path = "/sso/return", method = "post", operation_id = "return_to_sso_with_form_data" )] async fn api_return_to_sso_post( &self, req: &Request, session: &Session, ctx: Data<&UnauthenticatedRequestContext>, data: Form, ) -> Result { let url = self .api_return_to_sso_get_common(req, session, ctx, &data.code) .await? .unwrap_or_else(|x| make_redirect_url(&x)); let serialized_url = serde_json::to_string(&url)?; Ok(ReturnToSsoPostResponse::Redirect( poem_openapi::payload::Html(format!( "\n Redirecting to {url}... " )), )) } async fn api_return_to_sso_get_common( &self, req: &Request, session: &Session, ctx: Data<&UnauthenticatedRequestContext>, code: &Option, ) -> Result, WarpgateError> { // pull services locally for convenience let services = &ctx.services; let Some(context) = session.get::(SSO_CONTEXT_SESSION_KEY) else { return Ok(Err("Not in an active SSO process".to_string())); }; let Some(ref code) = *code else { return Ok(Err( "No authorization code in the return URL request".to_string() )); }; let response = context .request .verify_code((*code).clone()) .await .inspect_err(|e| { warn!("Failed to verify SSO code: {e:?}"); })?; if !response.email_verified.unwrap_or(true) { return Ok(Err("The SSO account's e-mail is not verified".to_string())); } let Some(email) = response.email else { return Ok(Err("No e-mail information in the SSO response".to_string())); }; info!("SSO login as {email}"); let providers_config = ctx.services.config.lock().await.store.sso_providers.clone(); let mut iter = providers_config.iter(); let Some(provider_config) = iter.find(|x| x.name == context.provider) else { return Ok(Err(format!("No provider matching {}", context.provider))); }; let cred = AuthCredential::Sso { provider: context.provider.clone(), email: email.clone(), }; let username = services .config_provider .lock() .await .username_for_sso_credential( &cred, response.preferred_username, provider_config.clone(), ) .await?; let Some(username) = username else { return Ok(Err(format!("No user matching {email}"))); }; let mut auth_state_store = services.auth_state_store.lock().await; let state_arc = get_auth_state_for_request(&username, session, &mut auth_state_store).await?; let mut state = state_arc.lock().await; let mut cp = services.config_provider.lock().await; if state.user_info().username != username { return Ok(Err(format!( "Incorrect account for SSO authentication ({username})" ))); } if cp.validate_credential(&username, &cred).await? { state.add_valid_credential(cred); } else { return Ok(Err(format!( "Failed to validate SSO credential for {username}" ))); } if let AuthResult::Accepted { user_info } = state.verify() { auth_state_store.complete(state.id()).await; authorize_session(req, &ctx, user_info).await?; session.set_sso_login_state(SsoLoginState { provider: context.provider, token: response.id_token, supports_single_logout: context.supports_single_logout, }); } let mappings = provider_config.provider.role_mappings(); if let Some(remote_groups) = response.access_roles { // If mappings is not set, all groups are subject to sync // and names won't be remapped let managed_role_names = mappings .as_ref() .map(|m| m.iter().flat_map(|(_, v)| v.roles()).collect::>()); let mut active_role_names: Vec = if let Some(ref mappings) = mappings { // Apply wildcard "*" mapping if user has any groups let mut roles: Vec = if !remote_groups.is_empty() { mappings.get("*").map(|v| v.roles()).unwrap_or_default() } else { Vec::new() }; // Apply specific group mappings for group in &remote_groups { if let Some(mapping) = mappings.get(group) { roles.extend(mapping.roles()); } } roles } else { // No mappings configured, pass through group names as-is remote_groups }; active_role_names.sort(); active_role_names.dedup(); debug!("SSO role mappings for {username}: active={active_role_names:?}, managed={managed_role_names:?}"); cp.apply_sso_role_mappings(&username, managed_role_names, active_role_names) .await?; } // import admin roles from claim if present if let Some(remote_admins) = response.admin_roles { let admin_map = provider_config.provider.admin_role_mappings(); // compute managed list from mapping values (or all role names if no mapping provided) let managed_admin_names: Option> = admin_map .as_ref() .map(|m| m.values().flat_map(|v| v.roles()).collect()); let active_admin_names: Vec<_> = if let Some(ref mappings) = admin_map { remote_admins .iter() .flat_map(|r| mappings.get(r).map(|v| v.roles()).into_iter().flatten()) .collect() } else { remote_admins.clone() }; debug!("SSO admin role mappings for {username}: active={active_admin_names:?}, managed={managed_admin_names:?}"); cp.apply_sso_admin_role_mappings(&username, managed_admin_names, active_admin_names) .await?; } let mut next_url = context .next_url .as_deref() .unwrap_or("/@warpgate#/login") .to_owned(); if let Some(ref host) = context.return_host { if next_url.starts_with('/') { next_url = format!("https://{}{}", host, next_url); } } Ok(Ok(next_url)) } #[oai( path = "/sso/logout", method = "get", operation_id = "initiate_sso_logout" )] async fn api_start_slo( &self, req: &Request, session: &Session, ctx: Data<&UnauthenticatedRequestContext>, session_middleware: Data<&Arc>>, ) -> Result { let Some(state) = session.get_sso_login_state() else { return Ok(StartSloResponse::NotInSsoSession); }; let config = ctx.services.config.lock().await; let return_url = config.construct_external_url(Some(req), None)?; debug!("Return URL: {}", &return_url); let Some(provider_config) = config .store .sso_providers .iter() .find(|p| p.name == state.provider) else { return Ok(StartSloResponse::NotFound); }; let client = SsoClient::new(provider_config.provider.clone())?; let logout_url = client.logout(state.token, return_url).await?; logout(session, session_middleware.lock().await.deref_mut()); Ok(StartSloResponse::Ok(Json(StartSloResponseParams { url: logout_url.to_string(), }))) } } ================================================ FILE: warpgate-protocol-http/src/api/targets_list.rs ================================================ use std::collections::HashMap; use futures::{stream, StreamExt}; use poem::web::Data; use poem_openapi::param::Query; use poem_openapi::payload::Json; use poem_openapi::{ApiResponse, Object, OpenApi}; use sea_orm::EntityTrait; use serde::Serialize; use warpgate_common::{Target as TargetConfig, TargetOptions, WarpgateError}; use warpgate_common_http::{ AuthenticatedRequestContext, RequestAuthorization, SessionAuthorization, }; use warpgate_core::ConfigProvider; use warpgate_db_entities::TargetGroup::BootstrapThemeColor; use warpgate_db_entities::{Target, TargetGroup}; use crate::api::AnySecurityScheme; use crate::common::endpoint_auth; pub struct Api; #[derive(Debug, Serialize, Clone, Object)] pub struct GroupInfo { pub id: uuid::Uuid, pub name: String, pub color: Option, } #[derive(Debug, Serialize, Clone, Object)] pub struct TargetSnapshot { pub name: String, pub description: String, pub kind: Target::TargetKind, pub external_host: Option, pub group: Option, pub default_database_name: Option, } #[derive(ApiResponse)] enum GetTargetsResponse { #[oai(status = 200)] Ok(Json>), } #[OpenApi] impl Api { #[oai( path = "/targets", method = "get", operation_id = "get_targets", transform = "endpoint_auth" )] async fn api_get_all_targets( &self, ctx: Data<&AuthenticatedRequestContext>, search: Query>, _sec_scheme: AnySecurityScheme, ) -> Result { // Fetch target groups for group information let services = &ctx.services; let groups: Vec = { let db = services.db.lock().await; TargetGroup::Entity::find().all(&*db).await }?; let group_map: HashMap = groups.iter().map(|g| (g.id, g)).collect(); let mut targets: Vec = { let mut config_provider = services.config_provider.lock().await; config_provider.list_targets().await? }; if let Some(ref search) = *search { let search = search.to_lowercase(); targets.retain(|t| { let group = t.group_id.and_then(|group_id| group_map.get(&group_id)); t.name.to_lowercase().contains(&search) || group .map(|g| g.name.to_lowercase().contains(&search)) .unwrap_or(false) }) } let auth_clone = ctx.auth.clone(); let targets: Vec<_> = stream::iter(targets) .filter(|t| { let services = services.clone(); let auth = auth_clone.clone(); let name = t.name.clone(); async move { match auth { RequestAuthorization::Session(SessionAuthorization::Ticket { target_name, .. }) => target_name == name, _ => { let mut config_provider = services.config_provider.lock().await; let Some(username) = auth.username() else { return false; }; matches!( config_provider.authorize_target(username, &name).await, Ok(true) ) } } } }) .collect::>() .await; let result: Vec = targets .into_iter() .map(|t| { let group = t.group_id.and_then(|group_id| { group_map.get(&group_id).map(|group| GroupInfo { id: group.id, name: group.name.clone(), color: group.color.clone(), }) }); TargetSnapshot { name: t.name.clone(), description: t.description.clone(), kind: (&t.options).into(), external_host: match t.options { TargetOptions::Http(ref opt) => opt.external_host.clone(), _ => None, }, default_database_name: match t.options { TargetOptions::Postgres(ref opt) => opt.default_database_name.clone(), TargetOptions::MySql(ref opt) => opt.default_database_name.clone(), _ => None, }, group, } }) .collect(); Ok(GetTargetsResponse::Ok(Json(result))) } } ================================================ FILE: warpgate-protocol-http/src/catchall.rs ================================================ use std::sync::Arc; use http::header::HOST; use poem::session::Session; use poem::web::websocket::WebSocket; use poem::web::{Data, FromRequest, Redirect}; use poem::{handler, Body, IntoResponse, Request, Response}; use serde::Deserialize; use tokio::sync::Mutex; use tracing::*; use warpgate_common::{Target, TargetHTTPOptions, TargetOptions}; use warpgate_common_http::{ AuthenticatedRequestContext, RequestAuthorization, SessionAuthorization, }; use warpgate_core::{ConfigProvider, WarpgateServerHandle}; use crate::common::SessionExt; use crate::proxy::{proxy_normal_request, proxy_websocket_request}; #[derive(Deserialize)] struct QueryParams { #[serde(rename = "warpgate-target")] warpgate_target: Option, } pub fn target_select_redirect() -> Response { Redirect::temporary("/@warpgate").into_response() } #[handler] pub async fn catchall_endpoint( req: &Request, ws: Option, session: &Session, body: Body, ctx: Data<&AuthenticatedRequestContext>, server_handle: Option>>>, ) -> poem::Result { let target_and_options = get_target_for_request(req, &ctx).await?; let Some((target, options)) = target_and_options else { return Ok(target_select_redirect()); }; session.set_target_name(target.name.clone()); if let Some(server_handle) = server_handle { server_handle.lock().await.set_target(&target).await?; } let span = info_span!("", target=%target.name); Ok(match ws { Some(ws) => proxy_websocket_request(req, ws, &options) .instrument(span) .await? .into_response(), None => proxy_normal_request(req, *ctx, body, &options) .instrument(span) .await? .into_response(), }) } async fn get_target_for_request( req: &Request, ctx: &AuthenticatedRequestContext, ) -> poem::Result> { let session = <&Session>::from_request_without_body(req).await?; let params: QueryParams = req.params()?; let selected_target_name; let need_role_auth; let request_host = req .header(HOST) .map(|h| h.split(':').next().unwrap_or(h).to_string()) .or_else(|| req.original_uri().host().map(|x| x.to_string())); let host_based_target_name = if let Some(host) = request_host { let found = ctx .services .config_provider .lock() .await .list_targets() .await? .iter() .filter_map(|t| match t.options { TargetOptions::Http(ref options) => Some((t, options)), _ => None, }) .find(|(_, o)| o.external_host.as_deref() == Some(&host)) .map(|(t, _)| t.name.clone()); if found.is_some() { debug!( "Domain rebinding detected: host={} -> target={:?}", host, found ); } found } else { None }; let username = match &ctx.auth { RequestAuthorization::Session(SessionAuthorization::Ticket { target_name, username, }) => { selected_target_name = Some(target_name.clone()); need_role_auth = false; username } RequestAuthorization::Session(SessionAuthorization::User(username)) => { need_role_auth = true; selected_target_name = if let Some(ref rebound_target) = host_based_target_name { Some(rebound_target.clone()) } else if let Some(warpgate_target) = params.warpgate_target { Some(warpgate_target) } else { session.get_target_name() }; username } RequestAuthorization::UserToken { .. } | RequestAuthorization::AdminToken => { return Ok(None) } }; let domain_rebinding_configured = host_based_target_name.is_some(); let final_target_name = selected_target_name.or(host_based_target_name); if let Some(target_name) = final_target_name { let target = { ctx.services .config_provider .lock() .await .list_targets() .await? .iter() .filter(|t| t.name == target_name) .filter_map(|t| match t.options { TargetOptions::Http(ref options) => Some((t, options)), _ => None, }) .next() .map(|(t, o)| (t.clone(), o.clone())) }; if let Some(target) = target { if need_role_auth && !ctx .services .config_provider .lock() .await .authorize_target(username, &target.0.name) .await? { return Ok(None); } return Ok(Some(target)); } } if domain_rebinding_configured { debug!( "Domain rebinding was configured for this host but target was not selected. This may indicate the target doesn't exist or user is not authorized." ); } Ok(None) } ================================================ FILE: warpgate-protocol-http/src/common.rs ================================================ use core::str; use std::sync::Arc; use anyhow::Context; use http::header::HOST; use http::{HeaderName, StatusCode}; use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; use poem::error::InternalServerError; use poem::session::Session; use poem::web::{Data, Redirect}; use poem::{Endpoint, EndpointExt, FromRequest, IntoResponse, Request, Response}; use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter}; use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use uuid::Uuid; use warpgate_common::auth::{AuthState, AuthStateUserInfo, CredentialKind}; use warpgate_common::{ProtocolName, WarpgateError}; use warpgate_common_http::auth::UnauthenticatedRequestContext; use warpgate_common_http::{ AuthenticatedRequestContext, RequestAuthorization, SessionAuthorization, }; use warpgate_core::{AuthStateStore, ConfigProvider}; use warpgate_db_entities::{User, UserAdminRoleAssignment}; use warpgate_sso::CoreIdToken; use crate::session::SessionStore; pub const PROTOCOL_NAME: ProtocolName = "HTTP"; static TARGET_SESSION_KEY: &str = "target_name"; static AUTH_SESSION_KEY: &str = "auth"; static AUTH_STATE_ID_SESSION_KEY: &str = "auth_state_id"; static AUTH_SSO_LOGIN_STATE: &str = "auth_sso_login_state"; pub static SESSION_COOKIE_NAME: &str = "warpgate-http-session"; pub static X_WARPGATE_TOKEN: HeaderName = HeaderName::from_static("x-warpgate-token"); /// Check if a host is localhost or 127.x.x.x (for development/testing scenarios) pub fn is_localhost_host(host: &str) -> bool { host == "localhost" || host == "127.0.0.1" || host.starts_with("127.") } #[derive(Serialize, Deserialize)] pub struct SsoLoginState { pub token: CoreIdToken, pub provider: String, pub supports_single_logout: bool, } pub trait SessionExt { fn get_target_name(&self) -> Option; fn set_target_name(&self, target_name: String); fn get_username(&self) -> Option; fn get_auth(&self) -> Option; fn set_auth(&self, auth: SessionAuthorization); fn get_auth_state_id(&self) -> Option; fn clear_auth_state(&self); fn get_sso_login_state(&self) -> Option; fn set_sso_login_state(&self, token: SsoLoginState); } impl SessionExt for Session { fn get_target_name(&self) -> Option { self.get(TARGET_SESSION_KEY) } fn set_target_name(&self, target_name: String) { self.set(TARGET_SESSION_KEY, target_name); } fn get_username(&self) -> Option { self.get_auth().map(|x| x.username().to_owned()) } fn get_auth(&self) -> Option { self.get(AUTH_SESSION_KEY) } fn set_auth(&self, auth: SessionAuthorization) { self.set(AUTH_SESSION_KEY, auth); } fn get_auth_state_id(&self) -> Option { self.get(AUTH_STATE_ID_SESSION_KEY) } fn clear_auth_state(&self) { self.remove(AUTH_STATE_ID_SESSION_KEY) } fn get_sso_login_state(&self) -> Option { self.get::(AUTH_SSO_LOGIN_STATE) .and_then(|x| serde_json::from_str(&x).ok()) } fn set_sso_login_state(&self, state: SsoLoginState) { if let Ok(json) = serde_json::to_string(&state) { self.set(AUTH_SSO_LOGIN_STATE, json) } } } #[derive(Clone, Serialize, Deserialize)] pub struct AuthStateId(pub Uuid); pub async fn is_user_admin(ctx: &AuthenticatedRequestContext) -> poem::Result { // A user is considered an administrator if they have any admin role assigned. let services = &ctx.services; // Admin tokens bypass the database check and are always full administrators. if let RequestAuthorization::AdminToken = ctx.auth { return Ok(true); } let username = match &ctx.auth { RequestAuthorization::Session(SessionAuthorization::User(username)) => username, RequestAuthorization::Session(SessionAuthorization::Ticket { .. }) => return Ok(false), RequestAuthorization::UserToken { username } => username, RequestAuthorization::AdminToken => unreachable!(), }; let db = services.db.lock().await; let Some(user_model) = User::Entity::find() .filter(User::Column::Username.eq(username)) .one(&*db) .await .map_err(InternalServerError)? else { return Ok(false); }; let count: u64 = UserAdminRoleAssignment::Entity::find() .filter(UserAdminRoleAssignment::Column::UserId.eq(user_model.id)) .count(&*db) .await .map_err(InternalServerError)?; Ok(count > 0) } pub async fn _inner_auth( ep: Arc, req: Request, ) -> poem::Result> { let ctx = Option::>::from_request_without_body(&req).await?; if ctx.is_none() { return Ok(None); } return ep.call(req).await.map(Some); } // TODO unify both based on the accept header pub fn endpoint_auth(e: E) -> impl Endpoint { e.around(|ep, req| async move { _inner_auth(ep, req) .await? .ok_or_else(|| poem::Error::from_status(StatusCode::UNAUTHORIZED)) }) } pub fn page_auth(e: E) -> impl Endpoint { e.around(|ep, req| async move { let err_resp = gateway_redirect(&req).into_response(); Ok(_inner_auth(ep, req) .await? .map(IntoResponse::into_response) .unwrap_or(err_resp)) }) } pub fn gateway_redirect(req: &Request) -> Response { let path = req .original_uri() .path_and_query() .map(|p| p.to_string()) .unwrap_or_else(|| "".into()); let path = format!( "/@warpgate#/login?next={}", utf8_percent_encode(&path, NON_ALPHANUMERIC), ); Redirect::temporary(path).into_response() } pub async fn get_auth_state_for_request( username: &str, session: &Session, store: &mut AuthStateStore, ) -> Result>, WarpgateError> { if let Some(id) = session.get_auth_state_id() { if !store.contains_key(&id.0) { session.remove(AUTH_STATE_ID_SESSION_KEY) } } if let Some(id) = session.get_auth_state_id() { let state = store.get(&id.0).ok_or(WarpgateError::InconsistentState)?; let existing_matched = state.lock().await.user_info().username == username; if existing_matched { return Ok(state); } } let (id, state) = store .create( None, username, crate::common::PROTOCOL_NAME, &[ CredentialKind::Password, CredentialKind::Sso, CredentialKind::Totp, ], ) .await?; session.set(AUTH_STATE_ID_SESSION_KEY, AuthStateId(id)); Ok(state) } pub async fn authorize_session( req: &Request, ctx: &UnauthenticatedRequestContext, user_info: AuthStateUserInfo, ) -> Result<(), WarpgateError> { let session_middleware = Data::<&Arc>>::from_request_without_body(req) .await .context("SessionStore not in request")?; let session = <&Session>::from_request_without_body(req) .await .context("Session not in request")?; let server_handle = session_middleware .lock() .await .create_handle_for(req, ctx) .await .context("create_handle_for")?; server_handle .lock() .await .set_user_info(user_info.clone()) .await?; session.set_auth(SessionAuthorization::User(user_info.username)); Ok(()) } pub async fn inject_request_authorization( ep: Arc, req: Request, ) -> poem::Result { let ctx = Data::<&UnauthenticatedRequestContext>::from_request_without_body(&req).await?; let session = <&Session>::from_request_without_body(&req).await?; let mut session_auth = session.get_auth(); if session_auth.is_some() { let config = ctx.services.config.lock().await; if let Ok(base_url) = config.construct_external_url(None, None) { if let Some(base_host) = base_url.host_str() { let request_host = req .header(HOST) .map(|h| h.split(':').next().unwrap_or(h).to_string()) .or_else(|| req.original_uri().host().map(|x| x.to_string())); if let Some(host) = request_host { // Validate request host matches base host or is a subdomain/localhost let is_localhost = is_localhost_host(&host); let is_authorized = host == base_host || host.ends_with(&format!(".{}", base_host)) || (is_localhost && base_host != "localhost" && base_host != "127.0.0.1"); if !is_authorized { tracing::warn!( "Session cookie rejected: request host '{}' is not authorized (base host: '{}'). Clearing session.", host, base_host ); session.clear(); session_auth = None; } } } } } let auth = match session_auth { Some(auth) => Some(RequestAuthorization::Session(auth)), None => match req.headers().get(&X_WARPGATE_TOKEN) { Some(token_from_header) => { let token_from_header = token_from_header .to_str() .map_err(poem::error::BadRequest)?; if Some(token_from_header) == ctx.services.admin_token.lock().await.as_deref() { Some(RequestAuthorization::AdminToken) } else if let Some(user) = ctx .services .config_provider .lock() .await .validate_api_token(token_from_header) .await? { Some(RequestAuthorization::UserToken { username: user.username, }) } else { None } } None => None, }, }; if let Some(auth) = auth { // build context and attach it instead of raw authorization let ctx = ctx.to_authenticated(auth); Ok(ep.data(ctx).call(req).await?) } else { Ok(ep.call(req).await?) } } ================================================ FILE: warpgate-protocol-http/src/error.rs ================================================ use http::StatusCode; use poem::IntoResponse; use tracing::error; pub fn error_page(e: poem::Error) -> impl IntoResponse { error!("{:?}", e); poem::web::Html(format!( r#"

Request failed

{e}

"# )).with_status(StatusCode::BAD_GATEWAY) } ================================================ FILE: warpgate-protocol-http/src/lib.rs ================================================ pub mod api; mod catchall; mod common; mod error; mod middleware; pub mod proxy; mod session; mod session_handle; use std::fmt::Debug; use std::sync::Arc; use std::time::Duration; use anyhow::{Context, Result}; use common::inject_request_authorization; pub use common::{SsoLoginState, PROTOCOL_NAME}; use http::HeaderValue; use poem::endpoint::{EmbeddedFileEndpoint, EmbeddedFilesEndpoint}; use poem::listener::{Listener, RustlsConfig}; use poem::middleware::SetHeader; use poem::session::{CookieConfig, MemoryStorage, ServerSession}; use poem::web::Data; use poem::{Endpoint, EndpointExt, FromRequest, IntoEndpoint, IntoResponse, Route, Server}; use poem_openapi::OpenApiService; use tokio::sync::Mutex; use tracing::*; use warpgate_admin::admin_api_app; use warpgate_common::version::warpgate_version; use warpgate_common::{GlobalParams, ListenEndpoint, WarpgateConfig}; use warpgate_common_http::auth::UnauthenticatedRequestContext; use warpgate_common_http::logging::{ get_client_ip, log_request_error, log_request_result, span_for_request, }; use warpgate_core::{ProtocolServer, Services}; use warpgate_tls::{ IntoTlsCertificateRelativePaths, RustlsSetupError, TlsCertificateAndPrivateKey, TlsCertificateBundle, TlsPrivateKey, }; use warpgate_web::Assets; use crate::common::{endpoint_auth, page_auth, SESSION_COOKIE_NAME}; use crate::error::error_page; use crate::middleware::{CookieHostMiddleware, TicketMiddleware}; use crate::session::{SessionStore, SharedSessionStorage}; use crate::session_handle::warpgate_server_handle_for_request; pub struct HTTPProtocolServer { services: Services, } impl HTTPProtocolServer { pub async fn new(services: &Services) -> Result { Ok(HTTPProtocolServer { services: services.clone(), }) } } fn make_session_storage() -> SharedSessionStorage { SharedSessionStorage(Arc::new(Mutex::new(Box::::default()))) } async fn load_certificate_and_key( from: &R, params: &GlobalParams, ) -> Result { Ok(TlsCertificateAndPrivateKey { certificate: TlsCertificateBundle::from_file( params.paths_relative_to().join(from.certificate_path()), ) .await?, private_key: TlsPrivateKey::from_file(params.paths_relative_to().join(from.key_path())) .await?, }) } async fn make_rustls_config( config: &WarpgateConfig, params: &GlobalParams, ) -> Result { let certificate_and_key = load_certificate_and_key(&config.store.http, params) .await .with_context(|| { format!( "loading TLS certificate and key: {}", config.store.http.certificate, ) })?; let mut cfg = RustlsConfig::new().fallback(certificate_and_key.into()); for sni in &config.store.http.sni_certificates { let certificate_and_key = load_certificate_and_key(sni, params) .await .with_context(|| format!("loading SNI TLS certificate: {sni:?}",))?; for name in certificate_and_key.certificate.sni_names()? { debug!(?name, source=?sni, "Adding SNI certificate"); cfg = cfg.certificate(name, certificate_and_key.clone().into()); } } Ok(cfg) } impl ProtocolServer for HTTPProtocolServer { async fn run(self, address: ListenEndpoint) -> Result<()> { let session_storage = make_session_storage(); let session_store = SessionStore::new(); let cache_bust = || { SetHeader::new().overriding( http::header::CACHE_CONTROL, HeaderValue::from_static("must-revalidate,no-cache,no-store"), ) }; let cache_static = || { SetHeader::new().overriding( http::header::CACHE_CONTROL, HeaderValue::from_static("max-age=86400"), ) }; let (cookie_max_age, session_max_age) = { let config = self.services.config.lock().await; ( config.store.http.cookie_max_age, config.store.http.session_max_age, ) }; // Set cookie domain to base host (e.g., ".warp.tavahealth.com") so it works for // the base host and all its subdomains (e.g., "foo.warp.tavahealth.com"). // This is more restrictive than using the parent domain and ensures cookies only // work for the base host and its subdomains, not sibling domains. let base_cookie_domain: Option = { let config = self.services.config.lock().await; match config.construct_external_url(None, None) { Ok(url) => { if let Some(host) = url.host_str() { // Use the base host directly with a leading dot (e.g., ".warp.tavahealth.com") // This allows cookies to work for: // - warp.tavahealth.com (exact match) // - foo.warp.tavahealth.com (subdomain) // - bar.warp.tavahealth.com (subdomain) // But NOT for: // - tavahealth.com (parent domain) // - reporting.tavahealth.com (sibling domain) let domain = format!(".{}", host); tracing::info!( "Cookie domain configured: {} (base host: {}) - cookies will work for {} and all its subdomains", domain, host, host ); Some(domain) } else { tracing::warn!("Failed to determine cookie domain - external_host may not be configured. Cookies will be scoped to request host, which may prevent cross-subdomain authentication."); None } } Err(e) => { tracing::warn!("Failed to construct external URL for cookie domain: {:?}. Cookies will be scoped to request host.", e); None } } }; // /@warpgate/ routes let at_warpgate_endpoints = || { let services = self.services.clone(); let api_service = { OpenApiService::new(crate::api::get(), "Warpgate user API", warpgate_version()) .server("/@warpgate/api") }; let openapi_ui_route = api_service.stoplight_elements(); let openapi_spec_route = api_service.spec_endpoint(); let admin_api_app = admin_api_app().into_endpoint(); Route::new() .nest("/api/playground", openapi_ui_route) .nest("/api", api_service.with(cache_bust())) .nest("/api/openapi.json", openapi_spec_route) .nest_no_strip( "/assets", EmbeddedFilesEndpoint::::new().with(cache_static()), ) .nest( "/admin/api", endpoint_auth(admin_api_app).with(cache_bust()), ) .at( "/admin", page_auth(EmbeddedFileEndpoint::::new("src/admin/index.html")) .with(cache_bust()), ) .at( "/api/auth/web-auth-requests/stream", endpoint_auth(api::auth::api_get_web_auth_requests_stream), ) .at( "", EmbeddedFileEndpoint::::new("src/gateway/index.html") .with(cache_bust()), ) .around({ let services = services.clone(); move |ep, req| { let services = services.clone(); async move { let method = req.method().clone(); let url = req.original_uri().clone(); let client_ip = get_client_ip(&req, &services).await; let response = ep.call(req).await.inspect_err(|e| { log_request_error(&method, &url, client_ip.as_deref(), e); })?; log_request_result( &method, &url, client_ip.as_deref(), &response.status(), ); Ok(response) } } }) }; let app = Route::new() .nest("/@warpgate", at_warpgate_endpoints()) .nest("/_warpgate", at_warpgate_endpoints()) .nest_no_strip( "/", page_auth(catchall::catchall_endpoint).around(move |ep, req| async move { Ok(match ep.call(req).await { Ok(response) => response.into_response(), Err(error) => error_page(error).into_response(), }) }), ) .around(inject_request_authorization) .around(move |ep, req| async move { let ctx = Data::<&UnauthenticatedRequestContext>::from_request_without_body(&req) .await? .clone(); let sm = Data::<&Arc>>::from_request_without_body(&req) .await? .clone(); let req = { sm.lock().await.process_request(req).await? }; let handle = warpgate_server_handle_for_request(&req).await.ok(); let span = match handle { Some(ref handle) => { let handle = handle.lock().await; span_for_request(&req, &ctx.services, Some(&*handle)).await? } None => span_for_request(&req, &ctx.services, None).await?, }; ep.call(req).instrument(span).await }) .with( SetHeader::new() .overriding(http::header::STRICT_TRANSPORT_SECURITY, "max-age=31536000"), ) .with(TicketMiddleware::new()) .with(ServerSession::new( CookieConfig::default() .secure(false) .max_age(cookie_max_age) .name(SESSION_COOKIE_NAME), session_storage.clone(), )) .with(CookieHostMiddleware::new(base_cookie_domain)) .data(UnauthenticatedRequestContext { services: self.services.clone(), }) .data(session_store.clone()) .data(session_storage); tokio::spawn(async move { loop { session_store.lock().await.vacuum(session_max_age).await; tokio::time::sleep(Duration::from_secs(60)).await; } }); let rustls_config = { let config = self.services.config.lock().await; make_rustls_config(&config, &self.services.global_params) .await .context("rustls setup")? }; Server::new(address.poem_listener().await?.rustls(rustls_config)) .run(app) .await?; Ok(()) } fn name(&self) -> &'static str { "HTTP" } } impl Debug for HTTPProtocolServer { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "HTTPProtocolServer") } } ================================================ FILE: warpgate-protocol-http/src/main.rs ================================================ use poem_openapi::OpenApiService; use regex::Regex; use warpgate_common::version::warpgate_version; use warpgate_protocol_http::api; #[allow(clippy::unwrap_used)] pub fn main() { let api_service = OpenApiService::new(api::get(), "Warpgate HTTP proxy", warpgate_version()) .server("/@warpgate/api"); let spec = api_service.spec(); let re = Regex::new(r"PaginatedResponse<(?P\w+)>").unwrap(); let spec = re.replace_all(&spec, "Paginated$name"); println!("{spec}"); } ================================================ FILE: warpgate-protocol-http/src/middleware/cookie_host.rs ================================================ use cookie::Cookie; use http::uri::Scheme; use poem::{Endpoint, IntoResponse, Middleware, Request, Response}; use crate::common::{is_localhost_host, SESSION_COOKIE_NAME}; #[derive(Clone)] pub struct CookieHostMiddleware { base_domain: Option, } impl CookieHostMiddleware { /// If `base_domain` is Some(".example.com"), the session cookie will be /// scoped to that domain (works across subdomains). If None, it falls /// back to the request host (previous behavior). pub fn new(base_domain: Option) -> Self { Self { base_domain } } } pub struct CookieHostMiddlewareEndpoint { inner: E, base_domain: Option, } impl Middleware for CookieHostMiddleware { type Output = CookieHostMiddlewareEndpoint; fn transform(&self, inner: E) -> Self::Output { CookieHostMiddlewareEndpoint { inner, base_domain: self.base_domain.clone(), } } } impl Endpoint for CookieHostMiddlewareEndpoint { type Output = Response; async fn call(&self, req: Request) -> poem::Result { let host = req .header(http::header::HOST) .map(|h| h.split(':').next().unwrap_or(h).to_string()) .or_else(|| req.original_uri().host().map(|x| x.to_string())); let scheme_https = req.original_uri().scheme() == Some(&Scheme::HTTPS); let header_https = req .header("x-forwarded-proto") .map(|h| h == "https") .unwrap_or(false); let is_https = scheme_https || header_https; let mut resp = self.inner.call(req).await?.into_response(); if let Some(host) = host { // Extract all Set-Cookie headers for modification let cookie_values: Vec = { let headers = resp.headers(); headers .get_all(http::header::SET_COOKIE) .iter() .filter_map(|v| v.to_str().ok()) .map(|s| s.to_string()) .collect() }; // Use the cookie crate to parse and modify cookies properly let mut modified_session_cookie: Option = None; for cookie_str in &cookie_values { if let Ok(mut cookie) = Cookie::parse(cookie_str) { if cookie.name() == SESSION_COOKIE_NAME { // For localhost/127.0.0.1, omit Domain attribute since browsers won't send cookies with a different domain let is_localhost = is_localhost_host(&host); let target_domain = if is_localhost { None // Omit Domain attribute for localhost - browser will scope to exact host } else if let Some(ref base) = self.base_domain { Some(base.clone()) } else { Some(host.clone()) }; // Set or remove Domain attribute using cookie crate methods if let Some(ref domain) = target_domain { cookie.set_domain(domain.clone()); } else { // For localhost, we need to remove the domain attribute cookie.unset_domain(); } // Add Secure and SameSite=None for HTTPS (required for cross-site cookies) if is_https { cookie.set_secure(true); cookie.set_same_site(cookie::SameSite::None); } if self.base_domain.is_none() { tracing::warn!( "CookieHostMiddleware: Setting session cookie domain to request host: {} (no base domain configured). This may prevent SSO from working across subdomains. Consider setting 'external_host' in config.", host ); } modified_session_cookie = Some(cookie.to_string()); tracing::debug!( "CookieHostMiddleware: Modified cookie - domain={:?}, is_https={}", target_domain, is_https ); break; } } } if modified_session_cookie.is_none() { tracing::debug!( "CookieHostMiddleware: No session cookie found in {} cookie(s)", cookie_values.len() ); } // Replace Set-Cookie headers: modified session cookie + other cookies unchanged if let Some(modified_cookie) = modified_session_cookie { let headers = resp.headers_mut(); headers.remove(http::header::SET_COOKIE); if let Ok(header_value) = modified_cookie.parse::() { headers.append(http::header::SET_COOKIE, header_value); } for cookie_str in &cookie_values { if let Ok(cookie) = Cookie::parse(cookie_str) { if cookie.name() != SESSION_COOKIE_NAME { if let Ok(header_value) = cookie_str.parse::() { headers.append(http::header::SET_COOKIE, header_value); } } } } } } Ok(resp) } } ================================================ FILE: warpgate-protocol-http/src/middleware/mod.rs ================================================ mod cookie_host; mod ticket; pub use cookie_host::*; pub use ticket::*; ================================================ FILE: warpgate-protocol-http/src/middleware/ticket.rs ================================================ use poem::session::Session; use poem::web::{Data, FromRequest}; use poem::{Endpoint, Middleware, Request}; use serde::Deserialize; use warpgate_common::Secret; use warpgate_common_http::auth::UnauthenticatedRequestContext; use warpgate_common_http::SessionAuthorization; use warpgate_core::{authorize_ticket, consume_ticket}; use crate::common::SessionExt; pub struct TicketMiddleware {} impl TicketMiddleware { pub fn new() -> Self { TicketMiddleware {} } } pub struct TicketMiddlewareEndpoint { inner: E, } impl Middleware for TicketMiddleware { type Output = TicketMiddlewareEndpoint; fn transform(&self, inner: E) -> Self::Output { TicketMiddlewareEndpoint { inner } } } #[derive(Deserialize)] struct QueryParams { #[serde(rename = "warpgate-ticket")] ticket: Option, } impl Endpoint for TicketMiddlewareEndpoint { type Output = E::Output; async fn call(&self, req: Request) -> poem::Result { let mut session_is_temporary = false; let session = <&Session>::from_request_without_body(&req).await?; let session = session.clone(); let ctx = Data::<&UnauthenticatedRequestContext>::from_request_without_body(&req).await?; { let params: QueryParams = req.params()?; let mut ticket_value = None; if let Some(t) = params.ticket { ticket_value = Some(t); } for h in req.headers().get_all(http::header::AUTHORIZATION) { let header_value = h.to_str().unwrap_or("").to_string(); if let Some((token_type, token_value)) = header_value.split_once(' ') { if &token_type.to_lowercase() == "warpgate" { ticket_value = Some(token_value.to_string()); session_is_temporary = true; } } } if let Some(ticket) = ticket_value { if let Some((ticket_model, _user_info)) = { let ticket_secret = Secret::new(ticket); if let Some((ticket, user_info)) = authorize_ticket(&ctx.services.db, &ticket_secret).await? { consume_ticket(&ctx.services.db, &ticket.id).await?; Some((ticket, user_info)) } else { None } } { session.set_auth(SessionAuthorization::Ticket { username: ticket_model.username, target_name: ticket_model.target, }); } } } let resp = self.inner.call(req).await; if session_is_temporary { session.clear(); } resp } } ================================================ FILE: warpgate-protocol-http/src/proxy.rs ================================================ use std::str::FromStr; use std::sync::Arc; use anyhow::{Context, Result}; use cookie::Cookie; use data_encoding::BASE64; use delegate::delegate; use futures::{StreamExt, TryStreamExt}; use http::header::HeaderName; use http::uri::{Authority, Scheme}; use http::{HeaderValue, Uri}; use poem::session::Session; use poem::web::websocket::WebSocket; use poem::{Body, FromRequest, IntoResponse, Request, Response}; use tokio_tungstenite::{connect_async_tls_with_config, tungstenite, Connector}; use tracing::*; use url::Url; use warpgate_common::helpers::websocket::pump_websocket; use warpgate_common::http_headers::{ DONT_FORWARD_HEADERS, X_FORWARDED_FOR, X_FORWARDED_HOST, X_FORWARDED_PROTO, }; use warpgate_common::{try_block, TargetHTTPOptions, WarpgateError}; use warpgate_common_http::logging::{get_client_ip, log_request_result}; use warpgate_common_http::{AuthenticatedRequestContext, SessionAuthorization}; use warpgate_tls::{configure_tls_connector, TlsMode}; use warpgate_web::lookup_built_file; use crate::common::SessionExt; static X_WARPGATE_USERNAME: HeaderName = HeaderName::from_static("x-warpgate-username"); static X_WARPGATE_AUTHENTICATION_TYPE: HeaderName = HeaderName::from_static("x-warpgate-authentication-type"); trait SomeResponse { fn status(&self) -> http::StatusCode; fn headers(&self) -> &http::HeaderMap; } impl SomeResponse for reqwest::Response { delegate! { to self { fn status(&self) -> http::StatusCode; fn headers(&self) -> &http::HeaderMap; } } } impl SomeResponse for http::Response { delegate! { to self { fn status(&self) -> http::StatusCode; fn headers(&self) -> &http::HeaderMap; } } } trait SomeRequestBuilder { fn header, V>(self, k: K, v: V) -> Self where HeaderValue: TryFrom, >::Error: Into; } impl SomeRequestBuilder for reqwest::RequestBuilder { fn header, V>(self, k: K, v: V) -> Self where HeaderValue: TryFrom, >::Error: Into, { self.header(k, v) } } impl SomeRequestBuilder for http::request::Builder { fn header, V>(self, k: K, v: V) -> Self where HeaderValue: TryFrom, >::Error: Into, { self.header(k, v) } } fn construct_uri(req: &Request, options: &TargetHTTPOptions, websocket: bool) -> Result { let target_uri = Uri::try_from(options.url.clone())?; let source_uri = req.uri().clone(); let authority = target_uri .authority() .context("No authority in the URL")? .to_string(); let authority: Authority = authority.try_into()?; let mut uri = http::uri::Builder::new() .authority(authority) .path_and_query( source_uri .path_and_query() .context("No path in the URL")? .clone(), ); let scheme = match options.tls.mode { TlsMode::Disabled => &Scheme::HTTP, TlsMode::Preferred => target_uri.scheme().context("No scheme in the URL")?, TlsMode::Required => &Scheme::HTTPS, }; uri = uri.scheme(scheme.clone()); #[allow(clippy::unwrap_used)] if websocket { uri = uri.scheme( Scheme::from_str(if scheme == &Scheme::from_str("http").unwrap() { "ws" } else { "wss" }) .unwrap(), ); } Ok(uri.build()?) } fn copy_client_response( client_response: &R, server_response: &mut poem::Response, ) { let mut headers = client_response.headers().clone(); for h in client_response.headers().iter() { if DONT_FORWARD_HEADERS.contains(h.0) { if let http::header::Entry::Occupied(e) = headers.entry(h.0) { e.remove_entry(); } } } server_response.headers_mut().extend(headers); server_response.set_status(client_response.status()); } fn rewrite_request(mut req: B, options: &TargetHTTPOptions) -> Result { if let Some(ref headers) = options.headers { for (k, v) in headers { req = req.header(HeaderName::try_from(k)?, v); } } Ok(req) } fn rewrite_response( resp: &mut Response, options: &TargetHTTPOptions, source_uri: &Uri, ) -> Result<()> { let target_uri = Uri::try_from(options.url.clone())?; let headers = resp.headers_mut(); if let Some(value) = headers.get_mut(http::header::LOCATION) { let location = Url::parse(&source_uri.to_string())?.join(value.to_str()?)?; let redirect_uri = Uri::try_from(location.to_string())?; if redirect_uri.authority() == target_uri.authority() { let old_value = value.clone(); *value = Uri::builder() .path_and_query( redirect_uri .path_and_query() .context("No path in URL")? .clone(), ) .build()? .to_string() .parse()?; debug!("Rewrote a redirect from {:?} to {:?}", old_value, value); } } if let http::header::Entry::Occupied(mut entry) = headers.entry(http::header::SET_COOKIE) { for value in entry.iter_mut() { try_block!({ let mut cookie = Cookie::parse(value.to_str()?)?; cookie.set_expires(cookie::Expiration::Session); *value = cookie.to_string().parse()?; } catch (error: anyhow::Error) { warn!(?error, header=?value, "Failed to parse response cookie") }) } } Ok(()) } fn copy_server_request(req: &Request, mut target: B) -> B { for k in req.headers().keys() { if DONT_FORWARD_HEADERS.contains(k) { continue; } target = target.header( k.clone(), req.headers() .get_all(k) .iter() .map(|v| v.to_str().map(|x| x.to_string())) .filter_map(|x| x.ok()) .collect::>() .join("; "), ); } target } fn inject_forwarding_headers(req: &Request, mut target: B) -> Result { #[allow(clippy::unwrap_used)] if let Some(host) = req.headers().get(http::header::HOST) { target = target.header( X_FORWARDED_HOST.clone(), host.to_str()?.split(':').next().unwrap(), ); } target = target.header(X_FORWARDED_PROTO.clone(), req.scheme().as_str()); if let Some(addr) = req.remote_addr().as_socket_addr() { target = target.header(X_FORWARDED_FOR.clone(), addr.ip().to_string()); } Ok(target) } async fn inject_own_headers(req: &Request, mut target: B) -> Result { let session = <&Session>::from_request_without_body(req).await?; if let Some(auth) = session.get_auth() { target = target.header(&X_WARPGATE_USERNAME, auth.username()).header( &X_WARPGATE_AUTHENTICATION_TYPE, match auth { SessionAuthorization::Ticket { .. } => "ticket", SessionAuthorization::User { .. } => "user", }, ); } Ok(target) } pub async fn proxy_normal_request( req: &Request, ctx: &AuthenticatedRequestContext, body: Body, options: &TargetHTTPOptions, ) -> poem::Result { let uri = construct_uri(req, options, false)?; tracing::debug!("URI: {:?}", uri); let mut client = reqwest::Client::builder() .gzip(true) .redirect(reqwest::redirect::Policy::none()) .connection_verbose(true); if let TlsMode::Required = options.tls.mode { client = client.https_only(true); } client = client.redirect(reqwest::redirect::Policy::custom({ let tls_mode = options.tls.mode; let uri = uri.clone(); move |attempt| { if tls_mode == TlsMode::Preferred && uri.scheme() == Some(&Scheme::HTTP) && attempt.url().scheme() == "https" { debug!("Following HTTP->HTTPS redirect"); attempt.follow() } else { attempt.stop() } } })); if !options.tls.verify { client = client.danger_accept_invalid_certs(true); } let client = client.build().context("Could not build request")?; let (authorization_header, uri) = extract_basic_auth(uri)?; let mut client_request = client.request(req.method().into(), uri.to_string()); client_request = copy_server_request(req, client_request); client_request = inject_forwarding_headers(req, client_request)?; client_request = inject_own_headers(req, client_request).await?; client_request = rewrite_request(client_request, options)?; if let Some(authorization_header) = authorization_header { client_request = client_request.header(http::header::AUTHORIZATION, authorization_header); } client_request = client_request.body(reqwest::Body::wrap_stream(body.into_bytes_stream())); let client_request = client_request.build().context("Could not build request")?; let client_response = client .execute(client_request) .await .map_err(|e| anyhow::anyhow!("Could not execute request: {e}"))?; let status = client_response.status(); let mut response: Response = "".into(); copy_client_response(&client_response, &mut response); copy_client_body(client_response, &mut response).await?; log_request_result( req.method(), req.original_uri(), get_client_ip(req, &ctx.services).await.as_deref(), &status, ); rewrite_response(&mut response, options, &uri)?; Ok(response) } async fn copy_client_body( client_response: reqwest::Response, response: &mut Response, ) -> Result<()> { if response.content_type().map(|c| c.starts_with("text/html")) == Some(true) && response.status() == 200 { copy_client_body_and_embed(client_response, response).await?; return Ok(()); } response.set_body(Body::from_bytes_stream( client_response .bytes_stream() .map_err(std::io::Error::other), )); Ok(()) } async fn copy_client_body_and_embed( client_response: reqwest::Response, response: &mut Response, ) -> Result<()> { let content = client_response.text().await?; let script_manifest = lookup_built_file("src/embed/index.ts")?; let mut inject = format!( r#""#, script_manifest.file ); for css_file in script_manifest.css.unwrap_or_default() { inject += &format!(r#""#,); } let before = ""; let content = content.replacen(before, &format!("{inject}{before}"), 1); response.headers_mut().remove(http::header::CONTENT_LENGTH); response .headers_mut() .remove(http::header::CONTENT_ENCODING); response.headers_mut().remove(http::header::CONTENT_TYPE); response .headers_mut() .remove(http::header::TRANSFER_ENCODING); response.headers_mut().insert( http::header::CONTENT_TYPE, "text/html; charset=utf-8".parse()?, ); response.set_body(content); Ok(()) } pub async fn proxy_websocket_request( req: &Request, ws: WebSocket, options: &TargetHTTPOptions, ) -> poem::Result { let uri = construct_uri(req, options, true)?; proxy_ws_inner(req, ws, uri.clone(), options) .await .map_err(|error| { tracing::error!(?uri, ?error, "WebSocket proxy failed"); error }) } /// Remove the username/password from the URL before using it for the Host header fn extract_basic_auth(uri: Uri) -> anyhow::Result<(Option, Uri)> { let uri_authority = uri .authority() .ok_or(WarpgateError::NoHostInUrl)? .to_string(); let parts = uri_authority.split('@').collect::>(); let host = parts.last().context("URL authority is empty")?; let uri = { let mut parts = uri.into_parts(); parts.authority = Some(Authority::from_str(host)?); Uri::from_parts(parts)? }; if parts.len() == 1 { return Ok((None, uri)); } #[allow(clippy::indexing_slicing)] // checked let creds = parts[0]; let auth_header = format!("Basic {}", BASE64.encode(creds.as_bytes())); let auth_value = HeaderValue::from_str(&auth_header)?; Ok((Some(auth_value), uri)) } async fn proxy_ws_inner( req: &Request, ws: WebSocket, uri: Uri, options: &TargetHTTPOptions, ) -> poem::Result { let (authorization_header, uri) = extract_basic_auth(uri)?; let mut client_request = http::request::Builder::new() .uri(uri.clone()) .header(http::header::CONNECTION, "Upgrade") .header(http::header::UPGRADE, "websocket") .header(http::header::SEC_WEBSOCKET_VERSION, "13") .header( http::header::SEC_WEBSOCKET_KEY, tungstenite::handshake::client::generate_key(), ) // tungstenite requires an explicit Host header .header( http::header::HOST, uri.authority() .ok_or(WarpgateError::NoHostInUrl) .context("no authority in the URL")? .to_string(), ); if let Some(authorization_header) = authorization_header { client_request = client_request.header(http::header::AUTHORIZATION, authorization_header); } client_request = copy_server_request(req, client_request); client_request = inject_forwarding_headers(req, client_request)?; client_request = inject_own_headers(req, client_request).await?; client_request = rewrite_request(client_request, options)?; let tls_config = configure_tls_connector(!options.tls.verify, false, None) .await .map_err(poem::error::InternalServerError)?; let connector = Connector::Rustls(Arc::new(tls_config)); let (client, client_response) = connect_async_tls_with_config( client_request .body(()) .map_err(poem::error::InternalServerError)?, None, true, Some(connector), ) .await .map_err(poem::error::BadGateway)?; tracing::info!("{:?} {:?} - WebSocket", client_response.status(), uri); let mut response = ws .on_upgrade(|socket| async move { let (client_sink, client_source) = client.split(); let (server_sink, server_source) = socket.split(); if let Err(error) = { let server_to_client = tokio::spawn(pump_websocket(server_source, client_sink, |msg| { Box::pin(async { tracing::debug!("Server: {:?}", msg); anyhow::Ok(msg) }) })); let client_to_server = tokio::spawn(pump_websocket(client_source, server_sink, |msg| { Box::pin(async { tracing::debug!("Client: {:?}", msg); anyhow::Ok(msg) }) })); server_to_client.await??; client_to_server.await??; debug!("Closing Websocket stream"); Ok::<_, anyhow::Error>(()) } { error!(?error, "Websocket stream error"); } Ok::<_, anyhow::Error>(()) }) .into_response(); copy_client_response(&client_response, &mut response); rewrite_response(&mut response, options, &uri)?; Ok(response) } ================================================ FILE: warpgate-protocol-http/src/session.rs ================================================ use std::collections::{BTreeMap, HashMap}; use std::sync::{Arc, Weak}; use std::time::{Duration, Instant}; use poem::session::{MemoryStorage, Session, SessionStorage}; use poem::web::{Data, RemoteAddr}; use poem::{FromRequest, Request}; use serde_json::Value; use tokio::sync::Mutex; use tracing::*; use warpgate_common::SessionId; use warpgate_common_http::auth::UnauthenticatedRequestContext; use warpgate_core::{SessionStateInit, State, WarpgateServerHandle}; use crate::common::PROTOCOL_NAME; use crate::session_handle::{HttpSessionHandle, SessionHandleCommand}; #[derive(Clone)] pub struct SharedSessionStorage(pub Arc>>); static POEM_SESSION_ID_SESSION_KEY: &str = "poem_session_id"; impl SessionStorage for SharedSessionStorage { async fn load_session<'a>( &'a self, session_id: &'a str, ) -> poem::Result>> { self.0.lock().await.load_session(session_id).await.map(|o| { o.map(|mut s| { s.insert( POEM_SESSION_ID_SESSION_KEY.to_string(), session_id.to_string().into(), ); s }) }) } /// Insert or update a session. async fn update_session<'a>( &'a self, session_id: &'a str, entries: &'a BTreeMap, expires: Option, ) -> poem::Result<()> { self.0 .lock() .await .update_session(session_id, entries, expires) .await } /// Remove a session by session id. async fn remove_session<'a>(&'a self, session_id: &'a str) -> poem::Result<()> { self.0.lock().await.remove_session(session_id).await } } pub struct SessionStore { session_handles: HashMap>>, session_timestamps: HashMap, this: Weak>, } static SESSION_ID_SESSION_KEY: &str = "session_id"; static REQUEST_COUNTER_SESSION_KEY: &str = "request_counter"; impl SessionStore { pub fn new() -> Arc> { Arc::new_cyclic(|me| { Mutex::new(Self { session_handles: HashMap::new(), session_timestamps: HashMap::new(), this: me.clone(), }) }) } pub async fn process_request(&mut self, req: Request) -> poem::Result { let session = <&Session>::from_request_without_body(&req).await?; let request_counter = session.get::(REQUEST_COUNTER_SESSION_KEY).unwrap_or(0); session.set(REQUEST_COUNTER_SESSION_KEY, request_counter + 1); if let Some(session_id) = session.get::(SESSION_ID_SESSION_KEY) { self.session_timestamps.insert(session_id, Instant::now()); // } else if request_counter == 5 { // Start logging sessions when they've got 5 requests // self.create_handle_for(&req).await?; }; Ok(req) } pub async fn create_handle_for( &mut self, req: &Request, ctx: &UnauthenticatedRequestContext, ) -> poem::Result>> { let session = <&Session>::from_request_without_body(req).await?; if let Some(handle) = self.handle_for(session) { return Ok(handle); } let remote_address = <&RemoteAddr>::from_request_without_body(req).await?; let session_storage = Data::<&SharedSessionStorage>::from_request_without_body(req).await?; let (session_handle, mut session_handle_rx) = HttpSessionHandle::new(); let server_handle = State::register_session( &ctx.services.state, &PROTOCOL_NAME, SessionStateInit { remote_address: remote_address.0.as_socket_addr().cloned(), handle: Box::new(session_handle), }, ) .await?; let id = server_handle.lock().await.id(); self.session_handles.insert(id, server_handle.clone()); session.set(SESSION_ID_SESSION_KEY, id); let Some(this) = self.this.upgrade() else { return Err(anyhow::anyhow!("Invalid session state").into()); }; tokio::spawn({ let session_storage = (*session_storage).clone(); let poem_session_id: Option = session.get(POEM_SESSION_ID_SESSION_KEY); async move { while let Some(command) = session_handle_rx.recv().await { match command { SessionHandleCommand::Close => { if let Some(ref poem_session_id) = poem_session_id { let _ = session_storage.remove_session(poem_session_id).await; } info!(%id, "Removed HTTP session"); let mut that = this.lock().await; that.session_handles.remove(&id); that.session_timestamps.remove(&id); } } } Ok::<_, anyhow::Error>(()) } }); self.session_timestamps.insert(id, Instant::now()); Ok(server_handle) } pub fn handle_for(&self, session: &Session) -> Option>> { session .get::(SESSION_ID_SESSION_KEY) .and_then(|id| self.session_handles.get(&id).cloned()) } pub fn remove_session(&mut self, session: &Session) { if let Some(id) = session.get::(SESSION_ID_SESSION_KEY) { self.session_handles.remove(&id); self.session_timestamps.remove(&id); } } pub async fn vacuum(&mut self, session_max_age: Duration) { let now = Instant::now(); let mut to_remove = vec![]; for (id, timestamp) in self.session_timestamps.iter() { if now.duration_since(*timestamp) > session_max_age { to_remove.push(*id); } } for id in to_remove { self.session_handles.remove(&id); self.session_timestamps.remove(&id); } } } ================================================ FILE: warpgate-protocol-http/src/session_handle.rs ================================================ use std::any::type_name; use std::sync::Arc; use poem::error::GetDataError; use poem::session::Session; use poem::web::Data; use poem::{FromRequest, Request}; use tokio::sync::{mpsc, Mutex}; use warpgate_core::{SessionHandle, WarpgateServerHandle}; use crate::session::SessionStore; #[derive(Clone, Debug, PartialEq, Eq)] pub enum SessionHandleCommand { Close, } pub struct HttpSessionHandle { sender: mpsc::UnboundedSender, } impl HttpSessionHandle { pub fn new() -> (Self, mpsc::UnboundedReceiver) { let (sender, receiver) = mpsc::unbounded_channel(); (HttpSessionHandle { sender }, receiver) } } impl SessionHandle for HttpSessionHandle { fn close(&mut self) { let _ = self.sender.send(SessionHandleCommand::Close); } } pub async fn warpgate_server_handle_for_request( req: &Request, ) -> poem::Result>> { let sm = Data::<&Arc>>::from_request_without_body(req).await?; let session = <&Session>::from_request_without_body(req).await?; Ok(sm .lock() .await .handle_for(session) .ok_or_else(|| GetDataError(type_name::()))?) } ================================================ FILE: warpgate-protocol-kubernetes/Cargo.toml ================================================ [package] edition = "2021" license = "Apache-2.0" name = "warpgate-protocol-kubernetes" version = "0.22.0" [dependencies] anyhow.workspace = true async-trait = { version = "0.1", default-features = false } base64 = { version = "0.22", default-features = false } bytes.workspace = true chrono = { version = "0.4", default-features = false, features = ["serde"] } dashmap = { version = "6.0", default-features = false } futures.workspace = true http = { version = "1.0", default-features = false } lazy_static = { version = "1.4", default-features = false } md5 = { version = "0.7", default-features = false } poem.workspace = true poem-openapi.workspace = true regex.workspace = true reqwest.workspace = true rustls.workspace = true sea-orm.workspace = true secrecy = { version = "0.10", default-features = false } serde = { version = "1.0", features = ["derive"], default-features = false } serde_json = { version = "1.0", default-features = false } thiserror.workspace = true tokio.workspace = true tokio-rustls = { version = "0.26", default-features = false } tracing.workspace = true url = { version = "2.0", default-features = false } uuid.workspace = true warpgate-common = { version = "*", path = "../warpgate-common", default-features = false } warpgate-common-http = { version = "*", path = "../warpgate-common-http", default-features = false } warpgate-core = { version = "*", path = "../warpgate-core", default-features = false } warpgate-db-entities = { version = "*", path = "../warpgate-db-entities", default-features = false } warpgate-tls = { version = "*", path = "../warpgate-tls", default-features = false } warpgate-ca = { version = "*", path = "../warpgate-ca", default-features = false } tokio-tungstenite.workspace = true reqwest-websocket.workspace = true ================================================ FILE: warpgate-protocol-kubernetes/src/correlator.rs ================================================ use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, Instant}; use poem::Request; use tokio::sync::Mutex; use warpgate_common::auth::AuthStateUserInfo; use warpgate_common::WarpgateError; use warpgate_common_http::logging::get_client_ip; use warpgate_core::{Services, SessionStateInit, State, WarpgateServerHandle}; use crate::session_handle::KubernetesSessionHandle; type CorrelationKey = (String, String, Option); // (username, target_name, ip) pub struct RequestCorrelator { handles: HashMap>, Instant)>, services: Services, } impl RequestCorrelator { pub fn new(services: &Services) -> Arc> { let this = Arc::new(Mutex::new(Self { handles: HashMap::new(), services: services.clone(), })); Self::spawn_vacuum_task(this.clone()); this } pub async fn session_for_request( &mut self, request: &Request, user_info: &AuthStateUserInfo, target_name: &str, ) -> Result>, WarpgateError> { let key = self .correlation_key_for_request(request, user_info, target_name) .await?; let now = Instant::now(); if let Some((handle, _created)) = self.handles.get(&key) { // Optionally, could update timestamp for LRU return Ok(handle.clone()); } let ip = get_client_ip(request, &self.services).await; let handle = State::register_session( &self.services.state, &crate::PROTOCOL_NAME, SessionStateInit { remote_address: ip.and_then(|x| x.parse().ok()), handle: Box::new(KubernetesSessionHandle), }, ) .await?; self.handles.insert(key, (handle.clone(), now)); Ok(handle) } async fn correlation_key_for_request( &self, request: &Request, user_info: &AuthStateUserInfo, target_name: &str, ) -> Result { let ip = get_client_ip(request, &self.services).await; Ok((user_info.username.clone(), target_name.into(), ip)) } /// Remove handles older than session_max_age pub async fn vacuum(&mut self) { let max_age = self .services .config .lock() .await .store .kubernetes .session_max_age; let now = Instant::now(); self.handles .retain(|_, (_, created)| now.duration_since(*created) < max_age); } /// Spawns a background task to periodically call vacuum fn spawn_vacuum_task(this: Arc>) { let interval = Duration::from_secs(60); tokio::spawn(async move { loop { tokio::time::sleep(interval).await; let mut guard = this.lock().await; guard.vacuum().await; } }); } } ================================================ FILE: warpgate-protocol-kubernetes/src/lib.rs ================================================ use std::fmt::Debug; use anyhow::Result; use warpgate_common::{ListenEndpoint, ProtocolName}; use warpgate_core::{ProtocolServer, Services}; mod correlator; pub mod recording; mod server; mod session_handle; pub use server::run_server; pub static PROTOCOL_NAME: ProtocolName = "Kubernetes"; #[derive(Clone)] pub struct KubernetesProtocolServer { services: Services, } impl KubernetesProtocolServer { pub async fn new(services: &Services) -> Result { Ok(Self { services: services.clone(), }) } } impl ProtocolServer for KubernetesProtocolServer { async fn run(self, address: ListenEndpoint) -> Result<()> { run_server(self.services, address).await } fn name(&self) -> &'static str { "Kubernetes" } } impl Debug for KubernetesProtocolServer { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("KubernetesProtocolServer").finish() } } ================================================ FILE: warpgate-protocol-kubernetes/src/recording.rs ================================================ use std::collections::HashMap; use std::sync::Arc; use anyhow::{Context, Result}; use bytes::Bytes; use chrono::{DateTime, Utc}; use poem_openapi::Object; use regex::Regex; use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use url::Url; use warpgate_common::SessionId; use warpgate_core::recordings::{Recorder, RecordingWriter, SessionRecordings, TerminalRecorder}; use warpgate_db_entities::Recording::RecordingKind; #[derive(Debug, Object)] #[oai(rename = "KubernetesRecordingItem")] pub struct KubernetesRecordingItemApiObject { pub timestamp: DateTime, pub request_method: String, pub request_path: String, pub request_body: serde_json::Value, pub response_status: Option, pub response_body: serde_json::Value, } #[derive(Serialize, Deserialize, Debug)] pub struct KubernetesRecordingItem { pub timestamp: DateTime, pub request_method: String, pub request_path: String, pub request_headers: std::collections::HashMap, #[serde(with = "warpgate_common::helpers::serde_base64")] pub request_body: Bytes, pub response_status: Option, pub response_body: Option>, } impl From for KubernetesRecordingItemApiObject { fn from(item: KubernetesRecordingItem) -> Self { KubernetesRecordingItemApiObject { timestamp: item.timestamp, request_method: item.request_method, request_path: item.request_path, request_body: serde_json::from_slice(&item.request_body[..]) .unwrap_or(serde_json::Value::Null), response_status: item.response_status, response_body: item .response_body .and_then(|body| serde_json::from_slice(&body[..]).ok()) .unwrap_or(serde_json::Value::Null), } } } pub struct KubernetesRecorder { writer: RecordingWriter, } impl KubernetesRecorder { async fn write_item( &mut self, item: &KubernetesRecordingItem, ) -> Result<(), warpgate_core::recordings::Error> { let mut serialized_item = serde_json::to_vec(&item).map_err(warpgate_core::recordings::Error::Serialization)?; serialized_item.push(b'\n'); self.writer.write(&serialized_item).await?; Ok(()) } pub async fn record_response( &mut self, method: &str, path: &str, headers: std::collections::HashMap, request_body: &[u8], status: u16, response_body: &[u8], ) -> Result<(), warpgate_core::recordings::Error> { self.write_item(&KubernetesRecordingItem { timestamp: Utc::now(), request_method: method.to_string(), request_path: path.to_string(), request_headers: headers, request_body: Bytes::from(request_body.to_vec()), response_status: Some(status), response_body: Some(response_body.to_vec()), }) .await } } impl Recorder for KubernetesRecorder { fn kind() -> RecordingKind { RecordingKind::Kubernetes } fn new(writer: RecordingWriter) -> Self { KubernetesRecorder { writer } } } // ---------- #[derive(Serialize, Deserialize, Debug)] #[serde(tag = "type")] pub enum SessionRecordingMetadata { #[serde(rename = "kubernetes-api")] Api, #[serde(rename = "kubernetes-exec")] Exec { namespace: String, pod: String, container: String, command: String, }, #[serde(rename = "kubernetes-attach")] Attach { namespace: String, pod: String, container: String, }, } pub async fn start_recording_api( session_id: &SessionId, recordings: &Arc>, ) -> anyhow::Result { let mut recordings = recordings.lock().await; recordings .start::( session_id, Some("api".into()), SessionRecordingMetadata::Api, ) .await .context("starting recording") } pub async fn start_recording_exec( session_id: &SessionId, recordings: &Arc>, metadata: Option, ) -> anyhow::Result { let mut recordings = recordings.lock().await; recordings .start::(session_id, None, metadata) .await .context("starting recording") } pub fn deduce_exec_recording_metadata(target_url: &Url) -> Option { let path = target_url.path(); let exec_url_regex = Regex::new(r"^/api/v1/namespaces/([^/]+)/pods/([^/]+)/(exec|attach)$").unwrap(); if let Some(captures) = exec_url_regex.captures(path) { let namespace = captures.get(1).map_or("unknown", |m| m.as_str()).into(); let pod = captures.get(2).map_or("unknown", |m| m.as_str()).into(); let operation = captures.get(3).map_or("unknown", |m| m.as_str()); let query = target_url.query().unwrap_or_default(); let parsed_query: HashMap<_, _> = url::form_urlencoded::parse(query.as_bytes()).collect(); let command = parsed_query .get("command") .cloned() .unwrap_or("unknown".into()) .into(); let container = parsed_query .get("container") .cloned() .unwrap_or("unknown".into()) .into(); return match operation { "exec" => Some(SessionRecordingMetadata::Exec { namespace, pod, container, command, }), "attach" => Some(SessionRecordingMetadata::Attach { namespace, pod, container, }), _ => None, }; } None } ================================================ FILE: warpgate-protocol-kubernetes/src/server/auth.rs ================================================ use anyhow::{Context, Result}; use poem::Request; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; use tracing::*; use warpgate_ca::{deserialize_certificate, serialize_certificate_serial}; use warpgate_common::auth::AuthStateUserInfo; use warpgate_common::{Target, TargetKubernetesOptions, TargetOptions, User}; use warpgate_core::{ConfigProvider, Services}; use warpgate_db_entities::{CertificateCredential, CertificateRevocation}; use crate::server::client_certs::RequestCertificateExt; pub async fn authenticate_and_get_target( req: &Request, target_name: &str, services: &Services, ) -> poem::Result<(AuthStateUserInfo, Target)> { // Check for Bearer token authentication (API tokens) if let Some(auth_header) = req.headers().get("authorization") { if let Ok(auth_str) = auth_header.to_str() { if let Some(token) = auth_str.strip_prefix("Bearer ") { let mut config_provider = services.config_provider.lock().await; if let Ok(Some(user)) = config_provider.validate_api_token(token).await { // Look up the specific target by name from the URL let targets = config_provider .list_targets() .await .context("listing targets")?; // Find the target with the specified name for target in targets { if target.name == target_name && matches!(target.options, TargetOptions::Kubernetes(_)) { if config_provider .authorize_target(&user.username, &target.name) .await .unwrap_or(false) { return Ok(((&user).into(), target)); } else { return Err(poem::Error::from_string( format!("Access denied to target: {}", target_name), poem::http::StatusCode::FORBIDDEN, )); } } } return Err(poem::Error::from_string( format!("Kubernetes target not found: {}", target_name), poem::http::StatusCode::NOT_FOUND, )); } } } } // Check for client certificate authentication // Use certificate extracted by middleware if present if let Some(client_cert) = req.client_certificate() { debug!("Found client certificate from middleware, validating against database"); match validate_client_certificate(&client_cert.der_bytes, services).await { Ok(Some(user_info)) => { // Look up the specific target by name from the URL let mut config_provider = services.config_provider.lock().await; let targets = config_provider .list_targets() .await .context("listing targets")?; // Find the target with the specified name for target in targets { if target.name == target_name && matches!(target.options, TargetOptions::Kubernetes(_)) { if config_provider .authorize_target(&user_info.username, &target.name) .await .unwrap_or(false) { return Ok((user_info, target)); } else { return Err(poem::Error::from_string( format!("Access denied to target: {}", target_name), poem::http::StatusCode::FORBIDDEN, )); } } } return Err(poem::Error::from_string( format!("Kubernetes target not found: {}", target_name), poem::http::StatusCode::NOT_FOUND, )); } Ok(None) => { debug!("Client certificate provided but not found in database"); } Err(e) => { warn!(error = %e, "Error validating client certificate"); } } } else { debug!("No client certificate provided in TLS connection"); } // Return unauthorized if no valid authentication found Err(poem::Error::from_string( "Unauthorized: Please provide either a valid Bearer token or a client certificate", poem::http::StatusCode::UNAUTHORIZED, )) } pub fn create_authenticated_client( k8s_options: &TargetKubernetesOptions, _auth_user: &Option, _services: &Services, ) -> anyhow::Result { debug!( server_url = ?k8s_options.cluster_url, auth_kind = ?k8s_options.auth, tls_config = ?k8s_options.tls, "Creating authenticated Kubernetes client" ); // Create HTTP client with the configuration let mut client_builder = reqwest::Client::builder(); if !k8s_options.tls.verify { client_builder = client_builder.danger_accept_invalid_certs(true); } match &k8s_options.auth { warpgate_common::KubernetesTargetAuth::Token(auth) => { let mut headers = reqwest::header::HeaderMap::new(); headers.insert( reqwest::header::AUTHORIZATION, reqwest::header::HeaderValue::from_str(&format!( "Bearer {}", auth.token.expose_secret() )) .context("setting Authorization header")?, ); client_builder = client_builder.default_headers(headers); } warpgate_common::KubernetesTargetAuth::Certificate(auth) => { // Expect PEM certificate and PEM private key in the auth config // Combine into a single PEM bundle for reqwest::Identity let cert_pem = auth.certificate.expose_secret(); let key_pem = auth.private_key.expose_secret(); let mut pem_bundle = String::new(); pem_bundle.push_str(cert_pem); if !pem_bundle.ends_with('\n') { pem_bundle.push('\n'); } pem_bundle.push_str(key_pem); if !pem_bundle.ends_with('\n') { pem_bundle.push('\n'); } let identity = reqwest::Identity::from_pem(pem_bundle.as_bytes()) .context("Invalid client certificate/key for Kubernetes upstream")?; client_builder = client_builder.identity(identity); } } Ok(client_builder) } // Helper function to validate client certificate against database pub async fn validate_client_certificate( cert_der: &[u8], services: &Services, ) -> anyhow::Result> { // Convert DER to PEM format for comparison let cert_pem = der_to_pem(cert_der)?; let db = services.db.lock().await; // Check if certificate is revoked (by serial number) let cert = deserialize_certificate(&cert_pem)?; let serial_b64 = serialize_certificate_serial(&cert); if CertificateRevocation::Entity::find() .filter(CertificateRevocation::Column::SerialNumberBase64.eq(&serial_b64)) .one(&*db) .await? .is_some() { warn!(serial = %serial_b64, "Client certificate is revoked"); return Ok(None); } // Find all certificate credentials and match against the provided certificate let cert_credentials = CertificateCredential::Entity::find() .find_with_related(warpgate_db_entities::User::Entity) .all(&*db) .await?; for (cert_credential, users) in cert_credentials { if let Some(user) = users.into_iter().next() { // Normalize both certificates for comparison let stored_cert = normalize_certificate_pem(&cert_credential.certificate_pem); let provided_cert = normalize_certificate_pem(&cert_pem); if stored_cert == provided_cert { debug!( user = user.username, cert_label = cert_credential.label, "Client certificate validated for user" ); // Update last_used timestamp let mut active_model: CertificateCredential::ActiveModel = cert_credential.into(); active_model.last_used = Set(Some(chrono::Utc::now())); if let Err(e) = active_model.update(&*db).await { warn!("Failed to update certificate last_used timestamp: {}", e); } return Ok(Some((&User::try_from(user)?).into())); } } } Ok(None) } fn der_to_pem(der_bytes: &[u8]) -> Result { use base64::engine::general_purpose; use base64::Engine as _; let cert_b64 = general_purpose::STANDARD.encode(der_bytes); let cert_lines: Vec = cert_b64 .chars() .collect::>() .chunks(64) .map(|chunk| chunk.iter().collect::()) .collect(); Ok(format!( "-----BEGIN CERTIFICATE-----\n{}\n-----END CERTIFICATE-----", cert_lines.join("\n") )) } fn normalize_certificate_pem(pem: &str) -> String { pem.lines() .filter(|line| !line.starts_with("-----")) .collect::>() .join("") .chars() .filter(|c| !c.is_whitespace()) .collect() } ================================================ FILE: warpgate-protocol-kubernetes/src/server/client_certs.rs ================================================ use std::sync::Arc; use base64::{self, Engine}; use poem::listener::Acceptor; use poem::web::{LocalAddr, RemoteAddr}; use poem::Addr; use rustls::pki_types::{CertificateDer, UnixTime}; use rustls::server::danger::{ClientCertVerified, ClientCertVerifier}; use rustls::{DigitallySignedStruct, ServerConfig, SignatureScheme}; use tokio_rustls::server::TlsStream; use tracing::{debug, warn}; /// Custom client certificate verifier that accepts any client certificate #[derive(Debug)] pub struct AcceptAnyClientCert; impl ClientCertVerifier for AcceptAnyClientCert { fn offer_client_auth(&self) -> bool { true } fn client_auth_mandatory(&self) -> bool { false } fn verify_client_cert( &self, _end_entity: &CertificateDer<'_>, _intermediates: &[CertificateDer<'_>], _now: UnixTime, ) -> Result { // Accept any client certificate - we'll extract and validate it later debug!("Client certificate received, accepting for later validation"); Ok(ClientCertVerified::assertion()) } fn verify_tls12_signature( &self, _message: &[u8], _cert: &CertificateDer<'_>, _dss: &DigitallySignedStruct, ) -> Result { Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) } fn verify_tls13_signature( &self, _message: &[u8], _cert: &CertificateDer<'_>, _dss: &DigitallySignedStruct, ) -> Result { Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) } fn supported_verify_schemes(&self) -> Vec { vec![ SignatureScheme::RSA_PKCS1_SHA1, SignatureScheme::RSA_PKCS1_SHA256, SignatureScheme::RSA_PKCS1_SHA384, SignatureScheme::RSA_PKCS1_SHA512, SignatureScheme::ECDSA_NISTP256_SHA256, SignatureScheme::ECDSA_NISTP384_SHA384, SignatureScheme::ECDSA_NISTP521_SHA512, SignatureScheme::RSA_PSS_SHA256, SignatureScheme::RSA_PSS_SHA384, SignatureScheme::RSA_PSS_SHA512, SignatureScheme::ED25519, SignatureScheme::ED448, ] } fn root_hint_subjects(&self) -> &[rustls::DistinguishedName] { &[] } } /// Custom TLS acceptor that captures client certificates and embeds them in remote_addr pub struct CertificateCapturingAcceptor { inner: T, tls_acceptor: tokio_rustls::TlsAcceptor, } impl CertificateCapturingAcceptor { pub fn new(inner: T, server_config: ServerConfig) -> Self { Self { inner, tls_acceptor: tokio_rustls::TlsAcceptor::from(Arc::new(server_config)), } } } impl Acceptor for CertificateCapturingAcceptor where T: Acceptor, { type Io = TlsStream; fn local_addr(&self) -> Vec { self.inner.local_addr() } async fn accept( &mut self, ) -> std::io::Result<(Self::Io, LocalAddr, RemoteAddr, http::uri::Scheme)> { let (stream, local_addr, remote_addr, _) = self.inner.accept().await?; // Perform TLS handshake let tls_stream = self.tls_acceptor.accept(stream).await?; // Extract client certificate from the TLS connection let enhanced_remote_addr = if let Some(cert_der) = extract_peer_certificates(&tls_stream) { // Serialize certificate as base64 and embed in remote_addr let cert_b64 = base64::engine::general_purpose::STANDARD.encode(&cert_der); let original_remote_addr_str = match &remote_addr.0 { Addr::SocketAddr(addr) => addr.to_string(), Addr::Unix(_) => remote_addr.to_string(), Addr::Custom(_, _) => "".into(), }; RemoteAddr(Addr::Custom( "captured-cert", format!("{original_remote_addr_str}|cert:{cert_b64}").into(), )) } else { remote_addr }; Ok(( tls_stream, local_addr, enhanced_remote_addr, http::uri::Scheme::HTTPS, )) } } /// Extract peer certificates from the TLS stream fn extract_peer_certificates(tls_stream: &TlsStream) -> Option> { // Get the TLS connection info let (_, tls_conn) = tls_stream.get_ref(); // Extract peer certificates - this gives us the certificate chain if let Some(peer_certs) = tls_conn.peer_certificates() { if let Some(end_entity_cert) = peer_certs.first() { debug!("Extracted client certificate from TLS stream"); return Some(end_entity_cert.as_ref().to_vec()); } } debug!("No client certificate found in TLS stream"); None } /// Certificate data extracted from client TLS connection #[derive(Debug, Clone)] pub struct ClientCertificate { pub der_bytes: Vec, } /// Middleware that extracts client certificates from enhanced remote_addr and stores them in request extensions pub struct CertificateExtractorMiddleware; impl poem::Middleware for CertificateExtractorMiddleware where E: poem::Endpoint, { type Output = CertificateExtractorEndpoint; fn transform(&self, ep: E) -> Self::Output { CertificateExtractorEndpoint { inner: ep } } } // Extracts client certificates stored in the request by [CertificateCapturingAcceptor] pub struct CertificateExtractorEndpoint { inner: E, } impl poem::Endpoint for CertificateExtractorEndpoint where E: poem::Endpoint, { type Output = E::Output; async fn call(&self, mut req: poem::Request) -> poem::Result { // Extract certificate from enhanced remote_addr if present if let RemoteAddr(Addr::Custom("captured-cert", value)) = req.remote_addr() { if let Some(cert_part) = value.split("|cert:").nth(1) { // Decode the base64 certificate match base64::engine::general_purpose::STANDARD.decode(cert_part) { Ok(cert_der) => { debug!( "Middleware: Successfully extracted client certificate from remote_addr" ); let client_cert = ClientCertificate { der_bytes: cert_der, }; // Store certificate in request extensions for later access req.extensions_mut().insert(client_cert); debug!("Middleware: Client certificate stored in request extensions"); } Err(e) => { warn!( "Middleware: Failed to decode client certificate from remote_addr: {}", e ); } } } } else { debug!("Middleware: No client certificate found in remote_addr"); } // Continue with the request self.inner.call(req).await } } /// Helper trait to easily extract client certificate from request pub trait RequestCertificateExt { /// Get the client certificate from request extensions, if present fn client_certificate(&self) -> Option<&ClientCertificate>; } impl RequestCertificateExt for poem::Request { fn client_certificate(&self) -> Option<&ClientCertificate> { self.extensions().get::() } } ================================================ FILE: warpgate-protocol-kubernetes/src/server/handlers.rs ================================================ use std::collections::HashMap; use std::sync::Arc; use anyhow::{bail, Context, Result}; use futures::{StreamExt, TryStreamExt}; use poem::web::websocket::{WebSocket, WebSocketStream}; use poem::web::{Data, Path}; use poem::{handler, Body, IntoResponse, Request, Response}; use reqwest_websocket::Upgrade; use serde::Deserialize; use tokio::sync::{mpsc, Mutex}; use tokio_tungstenite::tungstenite; use tracing::*; use url::Url; use warpgate_common::auth::AuthStateUserInfo; use warpgate_common::helpers::websocket::pump_websocket; use warpgate_common::http_headers::DONT_FORWARD_HEADERS; use warpgate_common::{SessionId, TargetKubernetesOptions, TargetOptions, WarpgateError}; use warpgate_common_http::auth::UnauthenticatedRequestContext; use warpgate_common_http::logging::{ get_client_ip, log_request_error, log_request_result, span_for_request, }; use warpgate_core::recordings::{TerminalRecorder, TerminalRecordingStreamId}; use warpgate_core::Services; use crate::correlator::RequestCorrelator; use crate::recording::{deduce_exec_recording_metadata, start_recording_api, start_recording_exec}; use crate::server::auth::{authenticate_and_get_target, create_authenticated_client}; fn construct_target_url( req: &Request, path: &str, k8s_options: &TargetKubernetesOptions, ) -> Result { let api_path = format!("/{}", path); let query = req.uri().query().unwrap_or(""); Ok(Url::parse(&if query.is_empty() { format!("{}{}", k8s_options.cluster_url, api_path) } else { format!("{}{}?{}", k8s_options.cluster_url, api_path, query) })?) } #[handler] #[allow(clippy::too_many_arguments)] pub async fn handle_api_request( ws: Option, req: &Request, Path((target_name, path)): Path<(String, String)>, body: Body, correlator: Data<&Arc>>, ctx: Data<&UnauthenticatedRequestContext>, ) -> Result { debug!( target_name = target_name, path_param = ?path, full_uri = %req.uri(), "Handling Kubernetes API request" ); let (user_info, target) = authenticate_and_get_target(req, &target_name, &ctx.services).await?; let TargetOptions::Kubernetes(k8s_options) = &target.options else { return Err(poem::Error::from_string( "Invalid target type", poem::http::StatusCode::BAD_REQUEST, )); }; let handle = correlator .lock() .await .session_for_request(req, &user_info, &target.name) .await?; let (session_id, log_span) = { let handle: tokio::sync::MutexGuard<'_, warpgate_core::WarpgateServerHandle> = handle.lock().await; handle.set_target(&target).await?; handle.set_user_info(user_info.clone()).await?; ( handle.id(), span_for_request(req, &ctx.services, Some(&*handle)).await?, ) }; async { let response = if let Some(ws) = ws { _handle_websocket_request_inner( ws, req, k8s_options, &path, user_info, session_id, &ctx.services, ) .await .map(IntoResponse::into_response) } else { _handle_normal_request_inner( req, body, k8s_options, &path, user_info, session_id, &ctx.services, ) .await .map(IntoResponse::into_response) .context("handling Kubernetes API request") }; let client_ip = get_client_ip(req, &ctx.services).await; let response = response.inspect_err(|e| { log_request_error(req.method(), req.original_uri(), client_ip.as_deref(), e); })?; log_request_result( req.method(), req.original_uri(), client_ip.as_deref(), &response.status(), ); Ok(response) } .instrument(log_span) .await } #[allow(clippy::too_many_arguments)] async fn _handle_normal_request_inner( req: &Request, body: Body, k8s_options: &TargetKubernetesOptions, path: &str, user_info: AuthStateUserInfo, session_id: SessionId, services: &Services, ) -> Result { let client = create_authenticated_client(k8s_options, &Some(user_info.username.clone()), services)? .build() .context("building reqwest client")?; debug!( "Target Kubernetes options: cluster_url={}, auth={:?}", k8s_options.cluster_url, match &k8s_options.auth { warpgate_common::KubernetesTargetAuth::Token(_) => "Token", warpgate_common::KubernetesTargetAuth::Certificate(_) => "Certificate", } ); let method = req.method().as_str(); // Construct the full URL to the Kubernetes API server (without target prefix) let full_url = construct_target_url(req, path, k8s_options).context("constructing target URL")?; // Extract headers let mut headers = HashMap::new(); for (name, value) in req.headers() { // Still forward Accept-Encoding to allow for chunked encoding if DONT_FORWARD_HEADERS.contains(name) && name != http::header::ACCEPT_ENCODING { continue; } if let Ok(mut value_str) = value.to_str().map(|s| s.to_string()) { if name == http::header::ACCEPT { let values = value .to_str() .unwrap_or_default() .split(',') .map(|s| s.trim()) .filter(|s| *s != "application/vnd.kubernetes.protobuf") // cannot parse protobuf yet .collect::>(); value_str = values.join(", "); } headers.insert(name.to_string(), value_str.to_string()); } } // Get request body let body_bytes = body.into_bytes().await.context("reading request body")?; // Record the request if recording is enabled let mut recorder_opt = { let enabled = { let config = services.config.lock().await; config.store.recordings.enable }; if enabled { match start_recording_api(&session_id, &services.recordings).await { Ok(recorder) => Some(recorder), Err(e) => { warn!("Failed to start recording: {}", e); None } } } else { None } }; // Forward request to Kubernetes API let mut request_builder = client.request( http::Method::from_bytes(method.as_bytes()).context("request method")?, full_url.clone(), ); // Add headers (excluding authorization, host, and content-length as they'll be set by reqwest) let mut upstream_headers = HashMap::new(); for (name, value) in &headers { let header_name_lower = name.to_lowercase(); if ![ "host", "content-length", "connection", "transfer-encoding", "authorization", ] .contains(&header_name_lower.as_str()) { if let (Ok(header_name), Ok(header_value)) = ( http::HeaderName::from_bytes(name.as_bytes()), http::HeaderValue::from_str(value), ) { request_builder = request_builder.header(header_name, header_value); upstream_headers.insert(name.clone(), value.clone()); } } else { debug!(header = name, "Filtering out header from upstream request"); } } debug!( filtered_headers = ?upstream_headers, "Headers being sent to upstream Kubernetes API" ); if !body_bytes.is_empty() { request_builder = request_builder.body(body_bytes.to_vec()); } // Debug logging for upstream request debug!( method = method, url = %full_url, headers = ?headers, body_size = body_bytes.len(), "Sending request to upstream Kubernetes API" ); let response = request_builder.send().await?; let status = response.status(); let response_headers = response.headers().clone(); debug!( method = method, url = %full_url, status = %status, response_headers = ?response_headers, "Received response from upstream Kubernetes API" ); let (response_body, body_for_recording) = { // k8s uses streaming chunked responses for watch API let transfer_encoding = response_headers .get(poem::http::header::TRANSFER_ENCODING) .and_then(|v| v.to_str().ok()) .unwrap_or_default() .to_lowercase(); let is_watch = req .uri() .query() .map(|q| q.split('&').any(|p| p.starts_with("watch=true"))) .unwrap_or(false); if transfer_encoding == "chunked" || is_watch { ( Body::from_bytes_stream(response.bytes_stream().map_err(std::io::Error::other)), None, ) } else { let bytes = response .bytes() .await .context("reading kubernetes response")?; (Body::from_bytes(bytes.clone()), Some(bytes.to_vec())) } }; // Record the response if let Some(ref mut recorder) = recorder_opt { if let Err(e) = recorder .record_response( method, full_url.as_ref(), headers, &body_bytes, status.as_u16(), body_for_recording.unwrap_or_default().as_ref(), ) .await { warn!("Failed to record Kubernetes response: {}", e); } } let mut poem_response = Response::builder().status(status); // Copy response headers for (name, value) in response_headers.iter() { if let Ok(poem_name) = poem::http::HeaderName::from_bytes(name.as_str().as_bytes()) { if let Ok(poem_value) = poem::http::HeaderValue::from_bytes(value.as_bytes()) { poem_response = poem_response.header(poem_name, poem_value); } } } Ok(poem_response.body(response_body)) } async fn run_websocket_recording(mut recorder: TerminalRecorder, mut rx: mpsc::Receiver>) { while let Some(data) = rx.recv().await { if data.is_empty() { continue; } let msg_type = data[0]; let data = data[1..].to_vec(); let result = match msg_type { 0..2 => { recorder .write( TerminalRecordingStreamId::from_usual_fd_number(msg_type) .unwrap_or_default(), &data, ) .await } 4 => { #[derive(Deserialize)] struct ResizeData { #[serde(rename = "Width")] width: u32, #[serde(rename = "Height")] height: u32, } if let Ok(resize_data) = serde_json::from_slice::(&data) { recorder .write_pty_resize(resize_data.width, resize_data.height) .await } else { continue; } } _ => continue, }; if let Err(e) = result { error!("Failed to write recording item: {}", e); } } } #[allow(clippy::too_many_arguments)] async fn _handle_websocket_request_inner( ws: WebSocket, req: &Request, k8s_options: &TargetKubernetesOptions, path: &str, user_info: AuthStateUserInfo, session_id: SessionId, services: &Services, ) -> anyhow::Result { let mut full_url = construct_target_url(req, path, k8s_options)?; if full_url.scheme() == "https" { let _ = full_url.set_scheme("wss"); } else { let _ = full_url.set_scheme("ws"); } let client = create_authenticated_client(k8s_options, &Some(user_info.username.clone()), services)? .http1_only() .build()?; let (recorder_tx, recorder_rx) = mpsc::channel::>(1000); { let enabled = { let config = services.config.lock().await; config.store.recordings.enable }; if enabled { match start_recording_exec( &session_id, &services.recordings, deduce_exec_recording_metadata(&full_url), ) .await { Err(e) => { error!("Failed to start recording: {}", e); } Ok(recorder) => { tokio::spawn(run_websocket_recording(recorder, recorder_rx)); } } } }; let ws_protocol = req .headers() .get("sec-websocket-protocol") .and_then(|h| h.to_str().ok()) .context("missing Sec-Websocket-Protocol request header")? .to_string(); let ws_handler_inner = async move |socket: WebSocketStream| { let client_response = client .get(full_url.clone()) .upgrade() .protocols(vec![ws_protocol]) .send() .await .context("sending websocket request to Kubernetes API")?; let status = client_response.status(); if status != http::StatusCode::SWITCHING_PROTOCOLS { let client_response = client_response.into_inner(); let body = client_response.text().await?; bail!("Unexpected websocket response status from Kubernetes API: {status}: {body}"); } let client_socket = client_response .into_websocket() .await .context("negotiating websocket connection with Kubernetes")?; let (client_sink, client_source) = client_socket.split(); let (server_sink, server_source) = socket.split(); let server_to_client = { let recorder_tx = recorder_tx.clone(); tokio::spawn(pump_websocket(server_source, client_sink, move |msg| { let recorder_tx = recorder_tx.clone(); async move { tracing::debug!("Server: {:?}", msg); if let tungstenite::Message::Binary(data) = &msg { let _ = recorder_tx.send(data.to_vec()).await; } anyhow::Ok(msg) } })) }; let client_to_server = tokio::spawn(pump_websocket(client_source, server_sink, move |msg| { let recorder_tx = recorder_tx.clone(); async move { tracing::debug!("Client: {:?}", msg); if let tungstenite::Message::Binary(data) = &msg { let _ = recorder_tx.send(data.to_vec()).await; } anyhow::Ok(msg) } })); server_to_client.await??; client_to_server.await??; debug!("Closing Websocket stream"); Ok::<(), anyhow::Error>(()) }; Ok(ws .protocols(vec![ "channel.k8s.io", "v2.channel.k8s.io", "v3.channel.k8s.io", "v4.channel.k8s.io", "v5.channel.k8s.io", ]) .on_upgrade(|socket| async move { ws_handler_inner(socket).await.inspect_err(|e| { error!("Websocket handling error: {e:?}"); })?; Ok::<(), anyhow::Error>(()) }) .into_response()) } ================================================ FILE: warpgate-protocol-kubernetes/src/server/mod.rs ================================================ use std::sync::Arc; use anyhow::{Context, Result}; use poem::listener::Listener; use poem::{EndpointExt, Route, Server}; use rustls::ServerConfig; use tracing::*; use warpgate_common::ListenEndpoint; use warpgate_common_http::auth::UnauthenticatedRequestContext; use warpgate_core::Services; use warpgate_tls::{ SingleCertResolver, TlsCertificateAndPrivateKey, TlsCertificateBundle, TlsPrivateKey, }; use crate::correlator::RequestCorrelator; use crate::server::client_certs::{AcceptAnyClientCert, CertificateCapturingAcceptor}; use crate::server::handlers::handle_api_request; mod auth; mod client_certs; mod handlers; use client_certs::CertificateExtractorMiddleware; pub async fn run_server(services: Services, address: ListenEndpoint) -> Result<()> { let correlator = RequestCorrelator::new(&services); let app = Route::new() .at("/:target_name/*path", handle_api_request) .with(poem::middleware::Cors::new()) .with(CertificateExtractorMiddleware) .data(UnauthenticatedRequestContext { services: services.clone(), }) .data(correlator); info!(?address, "Kubernetes protocol listening"); let certificate_and_key = { let config = services.config.lock().await; let certificate_path = services .global_params .paths_relative_to() .join(&config.store.kubernetes.certificate); let key_path = services .global_params .paths_relative_to() .join(&config.store.kubernetes.key); TlsCertificateAndPrivateKey { certificate: TlsCertificateBundle::from_file(&certificate_path) .await .with_context(|| { format!( "reading TLS certificate from '{}'", certificate_path.display() ) })?, private_key: TlsPrivateKey::from_file(&key_path).await.with_context(|| { format!("reading TLS private key from '{}'", key_path.display()) })?, } }; // Create TLS configuration with client certificate verification let tls_config = ServerConfig::builder_with_provider(Arc::new( rustls::crypto::aws_lc_rs::default_provider(), )) .with_safe_default_protocol_versions() .map_err(|e| anyhow::anyhow!("Failed to configure TLS protocol versions: {}", e))? .with_client_cert_verifier(Arc::new(AcceptAnyClientCert)) .with_cert_resolver(Arc::new(SingleCertResolver::new( certificate_and_key.clone(), ))); let tcp_acceptor = address.poem_listener().await?.into_acceptor().await?; let cert_capturing_acceptor = CertificateCapturingAcceptor::new(tcp_acceptor, tls_config); Server::new_with_acceptor(cert_capturing_acceptor) .run(app) .await .context("Kubernetes server error")?; Ok(()) } ================================================ FILE: warpgate-protocol-kubernetes/src/session_handle.rs ================================================ use warpgate_core::SessionHandle; pub struct KubernetesSessionHandle; impl SessionHandle for KubernetesSessionHandle { fn close(&mut self) { // no-op } } ================================================ FILE: warpgate-protocol-mysql/Cargo.toml ================================================ [package] edition = "2021" license = "Apache-2.0" name = "warpgate-protocol-mysql" version = "0.22.0" [dependencies] warpgate-common = { version = "*", path = "../warpgate-common", default-features = false } warpgate-tls = { version = "*", path = "../warpgate-tls", default-features = false } warpgate-core = { version = "*", path = "../warpgate-core", default-features = false } warpgate-db-entities = { version = "*", path = "../warpgate-db-entities", default-features = false } warpgate-database-protocols = { version = "*", path = "../warpgate-database-protocols", default-features = false } anyhow.workspace = true async-trait = { version = "0.1", default-features = false } futures.workspace = true tokio.workspace = true tracing.workspace = true uuid.workspace = true bytes.workspace = true mysql_common = { version = "0.34", default-features = false } flate2 = { version = "1", features = ["zlib"], default-features = false } rand.workspace = true sha1 = { version = "0.10", default-features = false } password-hash.workspace = true rustls.workspace = true tokio-rustls.workspace = true thiserror.workspace = true webpki = { version = "0.22", default-features = false } once_cell = { version = "1.17", default-features = false } ================================================ FILE: warpgate-protocol-mysql/src/client.rs ================================================ use std::sync::Arc; use bytes::BytesMut; use tokio::net::TcpStream; use tracing::*; use warpgate_common::TargetMySqlOptions; use warpgate_database_protocols::io::Decode; use warpgate_database_protocols::mysql::protocol::auth::AuthPlugin; use warpgate_database_protocols::mysql::protocol::connect::{ Handshake, HandshakeResponse, SslRequest, }; use warpgate_database_protocols::mysql::protocol::response::ErrPacket; use warpgate_database_protocols::mysql::protocol::Capabilities; use warpgate_tls::{configure_tls_connector, TlsMode}; use crate::common::compute_auth_challenge_response; use crate::error::MySqlError; use crate::stream::MySqlStream; pub struct MySqlClient { pub stream: MySqlStream>, pub _capabilities: Capabilities, } pub struct ConnectionOptions { pub collation: u8, pub database: Option, pub max_packet_size: u32, pub capabilities: Capabilities, } impl Default for ConnectionOptions { fn default() -> Self { ConnectionOptions { collation: 33, database: None, max_packet_size: 0xffff_ffff, capabilities: Capabilities::PROTOCOL_41 | Capabilities::PLUGIN_AUTH | Capabilities::FOUND_ROWS | Capabilities::LONG_FLAG | Capabilities::NO_SCHEMA | Capabilities::PLUGIN_AUTH_LENENC_DATA | Capabilities::CONNECT_WITH_DB | Capabilities::SESSION_TRACK | Capabilities::IGNORE_SPACE | Capabilities::INTERACTIVE | Capabilities::TRANSACTIONS | Capabilities::DEPRECATE_EOF | Capabilities::SECURE_CONNECTION | Capabilities::SSL, } } } impl MySqlClient { pub async fn connect( target: &TargetMySqlOptions, mut options: ConnectionOptions, ) -> Result { let stream = TcpStream::connect((target.host.clone(), target.port)).await?; stream.set_nodelay(true)?; let mut stream = MySqlStream::new(stream); options.capabilities.remove(Capabilities::SSL); if target.tls.mode != TlsMode::Disabled { options.capabilities |= Capabilities::SSL; } let Some(payload) = stream.recv().await? else { return Err(MySqlError::Eof); }; let handshake = Handshake::decode(payload)?; options.capabilities &= handshake.server_capabilities; if target.tls.mode == TlsMode::Required && !options.capabilities.contains(Capabilities::SSL) { return Err(MySqlError::TlsNotSupported); } info!(capabilities=?options.capabilities, "Target handshake"); if options.capabilities.contains(Capabilities::SSL) && target.tls.mode != TlsMode::Disabled { let accept_invalid_certs = !target.tls.verify; let accept_invalid_hostname = false; // ca + hostname verification let client_config = Arc::new( configure_tls_connector(accept_invalid_certs, accept_invalid_hostname, None) .await?, ); let req = SslRequest { collation: options.collation, max_packet_size: options.max_packet_size, }; stream.push(&req, options.capabilities)?; stream.flush().await?; stream = stream .upgrade(( target .host .clone() .try_into() .map_err(|_| MySqlError::InvalidDomainName)?, client_config, )) .await?; info!("Target connection upgraded to TLS"); } let mut response = HandshakeResponse { auth_plugin: None, auth_response: None, collation: options.collation, database: options.database, max_packet_size: options.max_packet_size, username: target.username.clone(), }; if handshake.auth_plugin == Some(AuthPlugin::MySqlNativePassword) { let scramble_bytes = [ &handshake.auth_plugin_data.first_ref()[..], &handshake.auth_plugin_data.last_ref()[..], ] .concat(); match scramble_bytes.try_into() as Result<[u8; 20], Vec> { Err(scramble_bytes) => { warn!("Invalid scramble length ({})", scramble_bytes.len()); } Ok(scramble) => { response.auth_plugin = Some(AuthPlugin::MySqlNativePassword); response.auth_response = Some( BytesMut::from( compute_auth_challenge_response( scramble, target.password.as_deref().unwrap_or(""), ) .map_err(MySqlError::other)? .as_bytes(), ) .freeze(), ); trace!(response=?response.auth_response, ?scramble, "auth"); } } } stream.push(&response, options.capabilities)?; stream.flush().await?; let Some(response) = stream.recv().await? else { return Err(MySqlError::Eof); }; if response.first() == Some(&0) || response.first() == Some(&0xfe) { debug!("Authorized"); } else if response.first() == Some(&0xff) { let error = ErrPacket::decode_with(response, options.capabilities)?; return Err(MySqlError::ProtocolError(format!( "handshake failed: {error:?}" ))); } else { return Err(MySqlError::ProtocolError(format!( "unknown response type {:?}", response.first() ))); } stream.reset_sequence_id(); Ok(Self { stream, _capabilities: options.capabilities, }) } } ================================================ FILE: warpgate-protocol-mysql/src/common.rs ================================================ use sha1::Digest; use warpgate_common::ProtocolName; pub const PROTOCOL_NAME: ProtocolName = "MySQL"; pub fn compute_auth_challenge_response( challenge: [u8; 20], password: &str, ) -> Result { password_hash::Output::new( &{ let password_sha: [u8; 20] = sha1::Sha1::digest(password).into(); let password_sha_sha: [u8; 20] = sha1::Sha1::digest(password_sha).into(); let password_seed_2sha_sha: [u8; 20] = sha1::Sha1::digest([challenge, password_sha_sha].concat()).into(); let mut result = password_sha; result .iter_mut() .zip(password_seed_2sha_sha.iter()) .for_each(|(x1, x2)| *x1 ^= *x2); result }[..], ) } ================================================ FILE: warpgate-protocol-mysql/src/error.rs ================================================ use std::error::Error; use warpgate_common::WarpgateError; use warpgate_database_protocols::error::Error as SqlxError; use warpgate_tls::{MaybeTlsStreamError, RustlsSetupError}; use crate::stream::MySqlStreamError; #[derive(thiserror::Error, Debug)] pub enum MySqlError { #[error("protocol error: {0}")] ProtocolError(String), #[error("sudden disconnection")] Eof, #[error("server doesn't offer TLS")] TlsNotSupported, #[error("client doesn't support TLS")] TlsNotSupportedByClient, #[error("TLS setup failed: {0}")] TlsSetup(#[from] RustlsSetupError), #[error("TLS stream error: {0}")] Tls(#[from] MaybeTlsStreamError), #[error("Invalid domain name")] InvalidDomainName, #[error("sqlx error: {0}")] Sqlx(#[from] SqlxError), #[error("MySQL stream error: {0}")] MySqlStream(#[from] MySqlStreamError), #[error("I/O: {0}")] Io(#[from] std::io::Error), #[error("packet decode error: {0}")] Decode(Box), #[error(transparent)] Warpgate(#[from] WarpgateError), #[error(transparent)] Other(Box), } impl MySqlError { pub fn other(err: E) -> Self { Self::Other(Box::new(err)) } pub fn decode(err: SqlxError) -> Self { match err { SqlxError::Decode(err) => Self::Decode(err), _ => Self::Sqlx(err), } } } ================================================ FILE: warpgate-protocol-mysql/src/lib.rs ================================================ mod client; mod common; mod error; mod session; mod session_handle; mod stream; use std::fmt::Debug; use std::sync::Arc; use anyhow::{Context, Result}; use futures::TryStreamExt; use rustls::server::NoClientAuth; use rustls::ServerConfig; use tracing::*; use warpgate_common::ListenEndpoint; use warpgate_core::{ProtocolServer, Services, SessionStateInit, State}; use warpgate_tls::{ ResolveServerCert, TlsCertificateAndPrivateKey, TlsCertificateBundle, TlsPrivateKey, }; use crate::session::MySqlSession; use crate::session_handle::MySqlSessionHandle; pub struct MySQLProtocolServer { services: Services, } impl MySQLProtocolServer { pub async fn new(services: &Services) -> Result { Ok(MySQLProtocolServer { services: services.clone(), }) } } impl ProtocolServer for MySQLProtocolServer { async fn run(self, address: ListenEndpoint) -> Result<()> { let certificate_and_key = { let config = self.services.config.lock().await; let paths_rel_to = self.services.global_params.paths_relative_to(); let certificate_path = paths_rel_to.join(&config.store.mysql.certificate); let key_path = paths_rel_to.join(&config.store.mysql.key); TlsCertificateAndPrivateKey { certificate: TlsCertificateBundle::from_file(&certificate_path) .await .with_context(|| { format!("reading SSL private key from '{}'", key_path.display()) })?, private_key: TlsPrivateKey::from_file(&key_path).await.with_context(|| { format!( "reading SSL certificate from '{}'", certificate_path.display() ) })?, } }; let tls_config = ServerConfig::builder_with_provider(Arc::new( rustls::crypto::aws_lc_rs::default_provider(), )) .with_safe_default_protocol_versions()? .with_client_cert_verifier(Arc::new(NoClientAuth)) .with_cert_resolver(Arc::new(ResolveServerCert(Arc::new( certificate_and_key.into(), )))); let mut listener = address.tcp_accept_stream().await?; loop { let Some(stream) = listener.try_next().await.context("accepting connection")? else { return Ok(()); }; let remote_address = stream.peer_addr().context("getting peer address")?; stream.set_nodelay(true)?; let tls_config = tls_config.clone(); let services = self.services.clone(); tokio::spawn(async move { let (session_handle, mut abort_rx) = MySqlSessionHandle::new(); let server_handle = State::register_session( &services.state, &crate::common::PROTOCOL_NAME, SessionStateInit { remote_address: Some(remote_address), handle: Box::new(session_handle), }, ) .await .context("registering session")?; let wrapped_stream = server_handle.lock().await.wrap_stream(stream).await?; let session = MySqlSession::new( server_handle, services, wrapped_stream, tls_config, remote_address, ) .await; let span = session.make_logging_span(); tokio::select! { result = session.run().instrument(span) => match result { Ok(_) => info!("Session ended"), Err(e) => error!(error=%e, "Session failed"), }, _ = abort_rx.recv() => { warn!("Session aborted by admin"); }, } Ok::<(), anyhow::Error>(()) }); } } fn name(&self) -> &'static str { "MySQL" } } impl Debug for MySQLProtocolServer { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("MySQLProtocolServer").finish() } } ================================================ FILE: warpgate-protocol-mysql/src/session.rs ================================================ use std::net::SocketAddr; use std::ops::Deref; use std::sync::Arc; use bytes::{Buf, Bytes, BytesMut}; use rand::Rng; use rustls::ServerConfig; use tokio::io::{AsyncRead, AsyncWrite}; use tokio::sync::Mutex; use tracing::*; use uuid::Uuid; use warpgate_common::auth::{ AuthCredential, AuthResult, AuthSelector, AuthStateUserInfo, CredentialKind, }; use warpgate_common::helpers::rng::get_crypto_rng; use warpgate_common::{Secret, TargetMySqlOptions, TargetOptions}; use warpgate_core::{ authorize_ticket, consume_ticket, ConfigProvider, Services, WarpgateServerHandle, }; use warpgate_database_protocols::io::{BufExt, Decode}; use warpgate_database_protocols::mysql::protocol::auth::AuthPlugin; use warpgate_database_protocols::mysql::protocol::connect::{ AuthSwitchRequest, Handshake, HandshakeResponse, }; use warpgate_database_protocols::mysql::protocol::response::{ErrPacket, OkPacket, Status}; use warpgate_database_protocols::mysql::protocol::text::Query; use warpgate_database_protocols::mysql::protocol::Capabilities; use crate::client::{ConnectionOptions, MySqlClient}; use crate::error::MySqlError; use crate::stream::MySqlStream; pub struct MySqlSession { stream: MySqlStream>, capabilities: Capabilities, challenge: [u8; 20], username: Option, database: Option, tls_config: Arc, server_handle: Arc>, id: Uuid, services: Services, remote_address: SocketAddr, } impl MySqlSession { pub async fn new( server_handle: Arc>, services: Services, stream: S, tls_config: ServerConfig, remote_address: SocketAddr, ) -> Self { let id = server_handle.lock().await.id(); Self { services, stream: MySqlStream::new(stream), capabilities: Capabilities::PROTOCOL_41 | Capabilities::PLUGIN_AUTH | Capabilities::FOUND_ROWS | Capabilities::LONG_FLAG | Capabilities::NO_SCHEMA | Capabilities::PLUGIN_AUTH_LENENC_DATA | Capabilities::CONNECT_WITH_DB | Capabilities::SESSION_TRACK | Capabilities::IGNORE_SPACE | Capabilities::INTERACTIVE | Capabilities::TRANSACTIONS | Capabilities::DEPRECATE_EOF | Capabilities::SECURE_CONNECTION | Capabilities::SSL, challenge: get_crypto_rng().gen(), tls_config: Arc::new(tls_config), username: None, database: None, server_handle, id, remote_address, } } pub fn make_logging_span(&self) -> tracing::Span { let client_ip = self.remote_address.ip().to_string(); match self.username { Some(ref username) => { info_span!("MySQL", session=%self.id, session_username=%username, %client_ip) } None => info_span!("MySQL", session=%self.id, %client_ip), } } pub async fn run(mut self) -> Result<(), MySqlError> { let mut challenge_1 = BytesMut::from(&self.challenge[..]); let challenge_2 = challenge_1.split_off(8); let challenge_chain = challenge_1.freeze().chain(challenge_2.freeze()); let handshake = Handshake { protocol_version: 10, server_version: "8.0.0-Warpgate".to_owned(), connection_id: 1, auth_plugin_data: challenge_chain, server_capabilities: self.capabilities, server_default_collation: 45, status: Status::empty(), auth_plugin: Some(AuthPlugin::MySqlNativePassword), }; self.stream.push(&handshake, ())?; self.stream.flush().await?; let resp = loop { let Some(payload) = self.stream.recv().await? else { return Err(MySqlError::Eof); }; let resp = HandshakeResponse::decode_with(payload, &mut self.capabilities) .map_err(MySqlError::decode)?; trace!(?resp, "Handshake response"); info!(capabilities=?self.capabilities, username=%resp.username, "User handshake"); if self.capabilities.contains(Capabilities::SSL) { if self.stream.is_tls() { break resp; } self.stream = self.stream.upgrade(self.tls_config.clone()).await?; continue; } else { self.send_error(1002, "Warpgate requires TLS - please enable it in your client: add `--ssl` on the CLI or add `?sslMode=PREFERRED` to your database URI").await?; return Err(MySqlError::TlsNotSupportedByClient); } }; if resp.auth_plugin == Some(AuthPlugin::MySqlClearPassword) { if let Some(mut response) = resp.auth_response.clone() { let password = Secret::new(response.get_str_nul()?); return self.run_authorization(resp, password).await; } } let req = AuthSwitchRequest { plugin: AuthPlugin::MySqlClearPassword, data: Bytes::new(), }; self.stream.push(&req, ())?; // self.push(&RawBytes::< self.stream.flush().await?; let Some(response) = &self.stream.recv().await? else { return Err(MySqlError::Eof); }; let password = Secret::new(response.clone().get_str_nul()?); self.run_authorization(resp, password).await } async fn send_error(&mut self, code: u16, message: &str) -> Result<(), MySqlError> { self.stream.push( &ErrPacket { error_code: code, error_message: message.to_owned(), sql_state: None, }, (), )?; self.stream.flush().await?; Ok(()) } pub async fn run_authorization( mut self, handshake: HandshakeResponse, password: Secret, ) -> Result<(), MySqlError> { let selector: AuthSelector = handshake.username.deref().into(); async fn fail( this: &mut MySqlSession, ) -> Result<(), MySqlError> { this.stream.push( &ErrPacket { error_code: 1, error_message: "Warpgate access denied".to_owned(), sql_state: None, }, (), )?; this.stream.flush().await?; Ok(()) } match selector { AuthSelector::User { username, target_name, } => { let state_arc = self .services .auth_state_store .lock() .await .create( Some(&self.server_handle.lock().await.id()), &username, crate::common::PROTOCOL_NAME, &[CredentialKind::Password], ) .await? .1; let mut state = state_arc.lock().await; let user_auth_result = { let credential = AuthCredential::Password(password); let mut cp = self.services.config_provider.lock().await; if cp.validate_credential(&username, &credential).await? { state.add_valid_credential(credential); } state.verify() }; match user_auth_result { AuthResult::Accepted { user_info } => { self.services .auth_state_store .lock() .await .complete(state.id()) .await; let target_auth_result = { self.services .config_provider .lock() .await .authorize_target(&user_info.username, &target_name) .await .map_err(MySqlError::other)? }; if !target_auth_result { warn!( "Target {} not authorized for user {}", target_name, user_info.username ); return fail(&mut self).await; } self.run_authorized(handshake, user_info, target_name).await } AuthResult::Rejected | AuthResult::Need(_) => fail(&mut self).await, // TODO SSO } } AuthSelector::Ticket { secret } => { match authorize_ticket(&self.services.db, &secret) .await .map_err(MySqlError::other)? { Some((ticket, user_info)) => { info!("Authorized for {} with a ticket", ticket.target); consume_ticket(&self.services.db, &ticket.id) .await .map_err(MySqlError::other)?; self.run_authorized(handshake, user_info, ticket.target) .await } _ => fail(&mut self).await, } } } } async fn run_authorized( mut self, handshake: HandshakeResponse, user_info: AuthStateUserInfo, target_name: String, ) -> Result<(), MySqlError> { self.stream.push( &OkPacket { affected_rows: 0, last_insert_id: 0, status: Status::empty(), warnings: 0, }, (), )?; self.stream.flush().await?; let target = { self.services .config_provider .lock() .await .list_targets() .await? .iter() .filter_map(|t| match t.options { TargetOptions::MySql(ref options) => Some((t, options)), _ => None, }) .find(|(t, _)| t.name == target_name) .map(|(t, opt)| (t.clone(), opt.clone())) }; let Some((target, mysql_options)) = target else { warn!("Selected target not found"); self.stream.push( &ErrPacket { error_code: 1, error_message: "Warpgate access denied".to_owned(), sql_state: None, }, (), )?; self.stream.flush().await?; return Ok(()); }; { let handle = self.server_handle.lock().await; handle.set_user_info(user_info).await?; handle.set_target(&target).await?; } self.run_authorized_inner(handshake, mysql_options).await } async fn run_authorized_inner( mut self, handshake: HandshakeResponse, options: TargetMySqlOptions, ) -> Result<(), MySqlError> { self.database = handshake.database.clone(); self.username = Some(handshake.username); if let Some(ref database) = handshake.database { info!("Selected database: {database}"); } let mut client = match MySqlClient::connect( &options, ConnectionOptions { collation: handshake.collation, database: handshake.database, max_packet_size: handshake.max_packet_size, capabilities: self.capabilities, }, ) .await { Err(error) => { error!(%error, "Target connection failed"); self.send_error(1045, "Access denied").await?; Err(error) } x => x, }?; loop { self.stream.reset_sequence_id(); client.stream.reset_sequence_id(); let Some(payload) = self.stream.recv().await? else { break; }; trace!(?payload, "server got packet"); let com = payload.first(); // COM_QUERY if com == Some(&0x03) { let query = Query::decode(payload)?; info!(query=%query.0, "SQL"); client.stream.push(&query, ())?; client.stream.flush().await?; let mut eof_ctr = 0; loop { let Some(response) = client.stream.recv().await? else { return Err(MySqlError::Eof); }; trace!(?response, "client got packet"); self.stream.push(&&response[..], ())?; self.stream.flush().await?; if let Some(com) = response.first() { if com == &0xfe { if self.capabilities.contains(Capabilities::DEPRECATE_EOF) { break; } eof_ctr += 1; if eof_ctr == 2 { // todo check multiple results break; } } if com == &0 || com == &0xff { break; } } } // COM_QUIT } else if com == Some(&0x01) { break; // COM_INIT_DB } else if com == Some(&0x02) { let mut buf = payload.clone(); buf.advance(1); let db = buf.get_str(buf.len())?; self.database = Some(db.clone()); info!("Selected database: {db}"); client.stream.push(&&payload[..], ())?; client.stream.flush().await?; self.passthrough_until_result(&mut client).await?; // COM_FIELD_LIST, COM_PING, COM_RESET_CONNECTION } else if com == Some(&0x04) || com == Some(&0x0e) || com == Some(&0x1f) { client.stream.push(&&payload[..], ())?; client.stream.flush().await?; self.passthrough_until_result(&mut client).await?; } else if let Some(com) = com { warn!("Unknown packet type {com}"); self.send_error(1047, "Not implemented").await?; } else { break; } } Ok(()) } async fn passthrough_until_result( &mut self, client: &mut MySqlClient, ) -> Result<(), MySqlError> { loop { let Some(response) = client.stream.recv().await? else { return Err(MySqlError::Eof); }; trace!(?response, "client got packet"); self.stream.push(&&response[..], ())?; self.stream.flush().await?; if let Some(com) = response.first() { if com == &0 || com == &0xff || com == &0xfe { break; } } } Ok(()) } } ================================================ FILE: warpgate-protocol-mysql/src/session_handle.rs ================================================ use tokio::sync::mpsc; use warpgate_core::SessionHandle; pub struct MySqlSessionHandle { abort_tx: mpsc::UnboundedSender<()>, } impl MySqlSessionHandle { pub fn new() -> (Self, mpsc::UnboundedReceiver<()>) { let (abort_tx, abort_rx) = mpsc::unbounded_channel(); (MySqlSessionHandle { abort_tx }, abort_rx) } } impl SessionHandle for MySqlSessionHandle { fn close(&mut self) { let _ = self.abort_tx.send(()); } } ================================================ FILE: warpgate-protocol-mysql/src/stream.rs ================================================ use bytes::{Bytes, BytesMut}; use mysql_common::proto::codec::error::PacketCodecError; use mysql_common::proto::codec::PacketCodec; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tracing::*; use warpgate_database_protocols::io::Encode; use warpgate_tls::{MaybeTlsStream, MaybeTlsStreamError, UpgradableStream}; #[derive(thiserror::Error, Debug)] pub enum MySqlStreamError { #[error("packet codec error: {0}")] Codec(#[from] PacketCodecError), #[error("I/O: {0}")] Io(#[from] std::io::Error), } pub struct MySqlStream where S: UpgradableStream, S: AsyncRead + AsyncWrite + Unpin, TS: AsyncRead + AsyncWrite + Unpin, { stream: MaybeTlsStream, codec: PacketCodec, inbound_buffer: BytesMut, outbound_buffer: BytesMut, } impl MySqlStream where S: UpgradableStream, S: AsyncRead + AsyncWrite + Unpin, TS: AsyncRead + AsyncWrite + Unpin, { pub fn new(stream: S) -> Self { Self { stream: MaybeTlsStream::new(stream), codec: PacketCodec::default(), inbound_buffer: BytesMut::new(), outbound_buffer: BytesMut::new(), } } pub fn push<'a, C, P: Encode<'a, C>>( &mut self, packet: &'a P, context: C, ) -> Result<(), MySqlStreamError> { let mut buf = vec![]; packet.encode_with(&mut buf, context); self.codec.encode(&mut &*buf, &mut self.outbound_buffer)?; Ok(()) } pub async fn flush(&mut self) -> std::io::Result<()> { trace!(outbound_buffer=?self.outbound_buffer, "sending"); self.stream.write_all(&self.outbound_buffer[..]).await?; self.outbound_buffer = BytesMut::new(); self.stream.flush().await?; Ok(()) } pub async fn recv(&mut self) -> Result, MySqlStreamError> { let mut payload = BytesMut::new(); loop { { let got_full_packet = self.codec.decode(&mut self.inbound_buffer, &mut payload)?; if got_full_packet { trace!(?payload, "received"); return Ok(Some(payload.freeze())); } } let read_bytes = self.stream.read_buf(&mut self.inbound_buffer).await?; if read_bytes == 0 { return Ok(None); } trace!(inbound_buffer=?self.inbound_buffer, "received chunk"); } } pub fn reset_sequence_id(&mut self) { self.codec.reset_seq_id(); } pub async fn upgrade( mut self, config: >::UpgradeConfig, ) -> Result { self.stream = self.stream.upgrade(config).await?; Ok(self) } pub fn is_tls(&self) -> bool { match self.stream { MaybeTlsStream::Raw(_) => false, MaybeTlsStream::Tls(_) => true, MaybeTlsStream::Upgrading => false, } } } ================================================ FILE: warpgate-protocol-postgres/Cargo.toml ================================================ [package] edition = "2021" license = "Apache-2.0" name = "warpgate-protocol-postgres" version = "0.22.0" [dependencies] warpgate-common = { version = "*", path = "../warpgate-common", default-features = false } warpgate-tls = { version = "*", path = "../warpgate-tls", default-features = false } warpgate-core = { version = "*", path = "../warpgate-core", default-features = false } anyhow.workspace = true async-trait = { version = "0.1", default-features = false } tokio.workspace = true tracing.workspace = true uuid.workspace = true bytes.workspace = true rustls.workspace = true tokio-rustls.workspace = true thiserror.workspace = true rustls-native-certs = { version = "0.8", default-features = false } pgwire = { version = "0.30", default-features = false, features = [ "server-api-aws-lc-rs", ] } rsasl = { version = "2.1.0", default-features = false, features = [ "config_builder", "scram-sha-2", "std", "plain", "provider", ] } futures.workspace = true humantime = { version = "2.1", default-features = false } socket2 = { version = "0.5", features = ["all"] } ================================================ FILE: warpgate-protocol-postgres/src/client.rs ================================================ use std::collections::BTreeMap; use std::fmt::Debug; use std::io::Write; use std::sync::Arc; use pgwire::messages::PgWireBackendMessage; use rsasl::config::SASLConfig; use rsasl::prelude::{Mechname, SASLClient}; use tokio::net::TcpStream; use tokio_rustls::client::TlsStream; use tracing::*; use warpgate_common::TargetPostgresOptions; use warpgate_tls::{configure_tls_connector, TlsMode}; use crate::error::PostgresError; use crate::stream::{PgWireGenericBackendMessage, PostgresEncode, PostgresStream}; pub struct PostgresClient { pub stream: PostgresStream>, } pub struct ConnectionOptions { pub protocol_number_major: u16, pub protocol_number_minor: u16, pub parameters: BTreeMap, } impl Default for ConnectionOptions { fn default() -> Self { ConnectionOptions { protocol_number_major: 3, protocol_number_minor: 0, parameters: BTreeMap::new(), } } } struct SaslBufferWriter<'a>(&'a mut Option>); impl Write for SaslBufferWriter<'_> { fn write(&mut self, buf: &[u8]) -> std::io::Result { if let Some(data) = self.0.as_mut() { data.extend_from_slice(buf); } else { *self.0 = Some(buf.to_vec()); } Ok(buf.len()) } fn flush(&mut self) -> std::io::Result<()> { Ok(()) } } impl PostgresClient { pub async fn connect( target: &TargetPostgresOptions, options: ConnectionOptions, ) -> Result { let stream = TcpStream::connect((target.host.clone(), target.port)).await?; stream.set_nodelay(true)?; let mut stream = PostgresStream::new(stream); if target.tls.mode != TlsMode::Disabled { stream.push(pgwire::messages::startup::SslRequest::new())?; stream.flush().await?; let Some(response) = stream .recv::() .await? else { return Err(PostgresError::Eof); }; match target.tls.mode { TlsMode::Disabled => unreachable!(), TlsMode::Required => { if response == pgwire::messages::response::SslResponse::Refuse { return Err(PostgresError::TlsNotSupported); } } TlsMode::Preferred => { if response == pgwire::messages::response::SslResponse::Refuse { warn!("TLS not supported by target"); } } } if response == pgwire::messages::response::SslResponse::Accept { let accept_invalid_certs = !target.tls.verify; let accept_invalid_hostname = false; // ca + hostname verification let client_config = Arc::new( configure_tls_connector(accept_invalid_certs, accept_invalid_hostname, None) .await?, ); stream = stream .upgrade(( target .host .clone() .try_into() .map_err(|_| PostgresError::InvalidDomainName)?, client_config, )) .await?; info!("Target connection upgraded to TLS"); } } let mut startup = pgwire::messages::startup::Startup::new(); startup.parameters = options.parameters.clone(); startup .parameters .insert("user".to_owned(), target.username.clone()); startup.protocol_number_major = options.protocol_number_major; startup.protocol_number_minor = options.protocol_number_minor; stream.push(startup)?; stream.flush().await?; loop { let Some(payload) = stream.recv::().await? else { return Err(PostgresError::Eof); }; let get_password = || { target .password .as_ref() .ok_or(PostgresError::PasswordRequired) }; match payload.0 { PgWireBackendMessage::ErrorResponse(err) => { return Err(PostgresError::from(err)); } PgWireBackendMessage::Authentication(auth) => match auth { pgwire::messages::startup::Authentication::Ok => { info!("Authenticated at target"); break; } pgwire::messages::startup::Authentication::CleartextPassword => { let password = get_password()?; let password_message = pgwire::messages::startup::Password::new(password.into()); stream.push(password_message)?; stream.flush().await?; } pgwire::messages::startup::Authentication::MD5Password(scramble) => { let password = get_password()?; let hashed = pgwire::api::auth::md5pass::hash_md5_password( &target.username, password, &scramble, ); let password_message = pgwire::messages::startup::Password::new(hashed); stream.push(password_message)?; stream.flush().await?; } pgwire::messages::startup::Authentication::SASL(mechanisms) => { let password = get_password()?; PostgresClient::run_sasl_auth( &mut stream, mechanisms, &target.username, password, ) .await?; } x => { return Err(PostgresError::ProtocolError(format!( "Unsupported authentication method: {:?}", x ))); } }, _ => { return Err(PostgresError::ProtocolError( "Expected authentication".to_owned(), )); } } } Ok(Self { stream }) } async fn run_sasl_auth( stream: &mut PostgresStream>, mechanisms: Vec, username: &str, password: &str, ) -> Result<(), PostgresError> { let cfg = SASLConfig::with_credentials(None, username.into(), password.into())?; let sasl = SASLClient::new(cfg); let mut session = sasl.start_suggested( &mechanisms .iter() .map(|x| Mechname::parse(x.as_bytes())) .filter_map(Result::ok) .collect::>(), )?; let mut data: Option> = None; if !session.are_we_first() { return Err(PostgresError::ProtocolError( "SASL mechanism expects server to send data first".to_owned(), )); } let mut is_first_response = true; while { let mut data_to_send = None; let state = { let mut writer = SaslBufferWriter(&mut data_to_send); session.step(data.as_deref(), &mut writer)? }; if let Some(data) = data_to_send { if is_first_response { let selected_mechanism = session.get_mechname(); debug!("Selected SASL mechanism: {selected_mechanism:?}"); stream.push(pgwire::messages::startup::SASLInitialResponse::new( selected_mechanism.to_string(), Some(data.into()), ))?; is_first_response = false; } else { stream.push(pgwire::messages::startup::SASLResponse::new(data.into()))?; }; stream.flush().await?; } state.is_running() } { let Some(payload) = stream.recv::().await? else { return Err(PostgresError::Eof); }; match payload.0 { PgWireBackendMessage::ErrorResponse(response) => return Err(response.into()), PgWireBackendMessage::Authentication( pgwire::messages::startup::Authentication::SASLContinue(msg), ) => { data = Some(msg.to_vec()); } PgWireBackendMessage::Authentication( pgwire::messages::startup::Authentication::SASLFinal(msg), ) => { data = Some(msg.to_vec()); } payload => { return Err(PostgresError::ProtocolError(format!( "Unexpected message: {payload:?}", ))); } } } Ok(()) } pub async fn recv(&mut self) -> Result, PostgresError> { self.stream .recv::() .await .map_err(Into::into) } pub async fn send( &mut self, message: M, ) -> Result<(), PostgresError> { self.stream.push(message)?; self.stream.flush().await?; Ok(()) } } ================================================ FILE: warpgate-protocol-postgres/src/common.rs ================================================ use warpgate_common::ProtocolName; pub const PROTOCOL_NAME: ProtocolName = "PostgreSQL"; ================================================ FILE: warpgate-protocol-postgres/src/error.rs ================================================ use std::error::Error; use std::string::FromUtf8Error; use pgwire::error::PgWireError; use pgwire::messages::response::ErrorResponse; use rsasl::prelude::{SASLError, SessionError}; use warpgate_common::WarpgateError; use warpgate_tls::{MaybeTlsStreamError, RustlsSetupError}; use crate::stream::PostgresStreamError; #[derive(thiserror::Error, Debug)] pub enum PostgresError { #[error("protocol error: {0}")] ProtocolError(String), #[error("remote error: {0:?}")] RemoteError(ErrorResponse), #[error("decode: {0}")] Decode(#[from] PgWireError), #[error("sudden disconnection")] Eof, #[error("stream: {0}")] Stream(#[from] PostgresStreamError), #[error("server doesn't offer TLS")] TlsNotSupported, #[error("TLS setup failed: {0}")] TlsSetup(#[from] RustlsSetupError), #[error("TLS stream error: {0}")] Tls(#[from] MaybeTlsStreamError), #[error("Invalid domain name")] InvalidDomainName, #[error("I/O: {0}")] Io(#[from] std::io::Error), #[error("UTF-8: {0}")] Utf8(#[from] FromUtf8Error), #[error("SASL: {0}")] Sasl(#[from] SASLError), #[error("SASL session: {0}")] SaslSession(#[from] SessionError), #[error("Password is required for authentication")] PasswordRequired, #[error(transparent)] Warpgate(#[from] WarpgateError), #[error(transparent)] Other(Box), } impl PostgresError { pub fn other(err: E) -> Self { Self::Other(Box::new(err)) } } impl From for PostgresError { fn from(e: ErrorResponse) -> Self { PostgresError::RemoteError(e) } } ================================================ FILE: warpgate-protocol-postgres/src/lib.rs ================================================ mod client; mod common; mod error; mod session; mod session_handle; mod stream; use std::fmt::Debug; use std::sync::Arc; use std::time::Duration; use anyhow::{Context, Result}; use futures::TryStreamExt; use rustls::server::NoClientAuth; use rustls::ServerConfig; use session::PostgresSession; use session_handle::PostgresSessionHandle; use socket2::{Socket, TcpKeepalive}; use tracing::*; use warpgate_common::ListenEndpoint; use warpgate_core::{ProtocolServer, Services, SessionStateInit, State}; use warpgate_tls::{ ResolveServerCert, TlsCertificateAndPrivateKey, TlsCertificateBundle, TlsPrivateKey, }; pub struct PostgresProtocolServer { services: Services, } impl PostgresProtocolServer { pub async fn new(services: &Services) -> Result { Ok(PostgresProtocolServer { services: services.clone(), }) } } impl ProtocolServer for PostgresProtocolServer { async fn run(self, address: ListenEndpoint) -> Result<()> { let certificate_and_key = { let config = self.services.config.lock().await; let paths_rel_to = self.services.global_params.paths_relative_to(); let certificate_path = paths_rel_to.join(&config.store.postgres.certificate); let key_path = paths_rel_to.join(&config.store.postgres.key); TlsCertificateAndPrivateKey { certificate: TlsCertificateBundle::from_file(&certificate_path) .await .with_context(|| { format!("reading SSL private key from '{}'", key_path.display()) })?, private_key: TlsPrivateKey::from_file(&key_path).await.with_context(|| { format!( "reading SSL certificate from '{}'", certificate_path.display() ) })?, } }; let tls_config = ServerConfig::builder_with_provider(Arc::new( rustls::crypto::aws_lc_rs::default_provider(), )) .with_safe_default_protocol_versions()? .with_client_cert_verifier(Arc::new(NoClientAuth)) .with_cert_resolver(Arc::new(ResolveServerCert(Arc::new( certificate_and_key.into(), )))); let mut listener = address .tcp_accept_stream() .await .context("accepting connection")?; loop { let Some(stream) = listener.try_next().await? else { return Ok(()); }; let remote_address = stream.peer_addr().context("getting peer address")?; // Enable TCP keepalive to prevent idle connections from timing out // This is especially important during web auth approval wait // Use socket2 to configure keepalive (tokio TcpStream doesn't expose it directly) let socket = Socket::from(stream.into_std()?); let keepalive = TcpKeepalive::new() .with_time(Duration::from_secs(60)) // Start keepalive after 60s of inactivity .with_interval(Duration::from_secs(10)) // Send probes every 10s .with_retries(3); // 3 retries before considering dead socket.set_tcp_keepalive(&keepalive)?; socket.set_nodelay(true)?; let stream = tokio::net::TcpStream::from_std(socket.into())?; let tls_config = tls_config.clone(); let services = self.services.clone(); tokio::spawn(async move { let (session_handle, mut abort_rx) = PostgresSessionHandle::new(); let server_handle = State::register_session( &services.state, &crate::common::PROTOCOL_NAME, SessionStateInit { remote_address: Some(remote_address), handle: Box::new(session_handle), }, ) .await?; let wrapped_stream = server_handle.lock().await.wrap_stream(stream).await?; let session = PostgresSession::new( server_handle, services, wrapped_stream, tls_config, remote_address, ) .await; let span = session.make_logging_span(); tokio::select! { result = session.run().instrument(span) => match result { Ok(_) => info!("Session ended"), Err(e) => error!(error=%e, "Session failed"), }, _ = abort_rx.recv() => { warn!("Session aborted by admin"); }, } Ok::<(), anyhow::Error>(()) }); } } fn name(&self) -> &'static str { "PostgreSQL" } } impl Debug for PostgresProtocolServer { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("PostgresProtocolServer").finish() } } ================================================ FILE: warpgate-protocol-postgres/src/session.rs ================================================ use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; use pgwire::error::ErrorInfo; use pgwire::messages::{PgWireBackendMessage, PgWireFrontendMessage}; use rustls::ServerConfig; use tokio::io::{AsyncRead, AsyncWrite}; use tokio::sync::Mutex; use tokio::time; use tokio_rustls::server::TlsStream; use tracing::*; use uuid::Uuid; use warpgate_common::auth::{ AuthCredential, AuthResult, AuthSelector, AuthStateUserInfo, CredentialKind, }; use warpgate_common::{Secret, TargetOptions, TargetPostgresOptions}; use warpgate_core::{ authorize_ticket, consume_ticket, ConfigProvider, Services, WarpgateServerHandle, }; use crate::client::{ConnectionOptions, PostgresClient}; use crate::error::PostgresError; use crate::stream::{PgWireGenericFrontendMessage, PgWireStartupOrSslRequest, PostgresStream}; pub struct PostgresSession { stream: PostgresStream>, tls_config: Arc, username: Option, database: Option, server_handle: Arc>, id: Uuid, services: Services, remote_address: SocketAddr, } impl PostgresSession { pub async fn new( server_handle: Arc>, services: Services, stream: S, tls_config: ServerConfig, remote_address: SocketAddr, ) -> Self { let id = server_handle.lock().await.id(); Self { services, tls_config: Arc::new(tls_config), stream: PostgresStream::new(stream), username: None, database: None, server_handle, id, remote_address, } } pub fn make_logging_span(&self) -> tracing::Span { let client_ip = self.remote_address.ip().to_string(); match self.username { Some(ref username) => { info_span!("PostgreSQL", session=%self.id, session_username=%username, %client_ip) } None => info_span!("PostgreSQL", session=%self.id, %client_ip), } } pub async fn run(mut self) -> Result<(), PostgresError> { let Some(mut initial_message) = self.stream.recv::().await? else { return Err(PostgresError::Eof); }; if let PgWireStartupOrSslRequest::SslRequest(_) = &initial_message { debug!("Received SslRequest"); self.stream .push(pgwire::messages::response::SslResponse::Accept)?; self.stream.flush().await?; self.stream = self.stream.upgrade(self.tls_config.clone()).await?; debug!("TLS setup complete"); let Some(next_message) = self.stream.recv::().await? else { return Err(PostgresError::Eof); }; initial_message = next_message; } let PgWireStartupOrSslRequest::Startup(startup) = initial_message else { return Err(PostgresError::ProtocolError("expected Startup".into())); }; let username = startup.parameters.get("user").cloned(); self.username = username.clone(); self.database = startup.parameters.get("database").cloned(); self.run_authorization(startup, &username.unwrap_or("".into())) .await } pub async fn run_authorization( mut self, startup: pgwire::messages::startup::Startup, username: &String, ) -> Result<(), PostgresError> { let selector: AuthSelector = username.into(); async fn fail( this: &mut PostgresSession, ) -> Result<(), PostgresError> { let error_info = ErrorInfo::new( "FATAL".to_owned(), "28P01".to_owned(), "Authentication failed".to_owned(), ); this.stream .push(pgwire::messages::response::ErrorResponse::from(error_info))?; this.stream.flush().await?; Ok(()) } match selector { AuthSelector::User { username, target_name, } => { let state_arc = self .services .auth_state_store .lock() .await .create( Some(&self.server_handle.lock().await.id()), &username, crate::common::PROTOCOL_NAME, &[CredentialKind::Password], ) .await? .1; let mut auth_ok_sent = false; loop { let user_auth_result = state_arc.lock().await.verify(); match user_auth_result { AuthResult::Accepted { user_info } => { self.services .auth_state_store .lock() .await .complete(state_arc.lock().await.id()) .await; let target_auth_result = { self.services .config_provider .lock() .await .authorize_target(&user_info.username, &target_name) .await .map_err(PostgresError::other)? }; if !target_auth_result { warn!("Target {target_name} not authorized for user {username}",); return fail(&mut self).await; } if !auth_ok_sent { self.stream .push(pgwire::messages::startup::Authentication::Ok)?; } return self.run_authorized(startup, user_info, target_name).await; } AuthResult::Need(kinds) => { if kinds.contains(&CredentialKind::Password) { self.stream.push( pgwire::messages::startup::Authentication::CleartextPassword, )?; self.stream.flush().await?; let Some(PgWireGenericFrontendMessage( PgWireFrontendMessage::PasswordMessageFamily(message), )) = self.stream.recv::().await? else { return Err(PostgresError::Eof); }; let password = Secret::from( message .into_password() .map_err(PostgresError::from)? .password, ); let mut state = state_arc.lock().await; let credential = AuthCredential::Password(password); if self .services .config_provider .lock() .await .validate_credential(&username, &credential) .await? { state.add_valid_credential(credential); } else { // Postgres CLI will just send the same password in a loop without prompting the user again return fail(&mut self).await; } } else if kinds.contains(&CredentialKind::WebUserApproval) { // Only WebUserApproval is needed, i.e. the password was either correct or not required, otherwise just fail early let identification_string = state_arc.lock().await.identification_string().to_owned(); let auth_state_id = *state_arc.lock().await.id(); let mut event = self .services .auth_state_store .lock() .await .subscribe(auth_state_id); let login_url_result = state_arc.lock().await.construct_web_approval_url( &*self.services.config.lock().await, ); let login_url = match login_url_result { Ok(login_url) => login_url, Err(error) => { error!(?error, "Failed to construct external URL"); return fail(&mut self).await; } }; if !auth_ok_sent { self.stream .push(pgwire::messages::startup::Authentication::Ok)?; auth_ok_sent = true; } self.stream .push(pgwire::messages::response::NoticeResponse::new(vec![ (b'S', "WARNING".into()), (b'V', "WARNING".into()), (b'C', "WG001".into()), (b'M', "Warpgate authentication: please open the following URL in your browser:".into()), (b'D', login_url.into()), (b'H', format!( "Make sure you're seeing this security key: {}\n", identification_string .chars() .map(|x| x.to_string()) .collect::>() .join(" ") )), ]))?; self.stream.flush().await?; if !matches!(event.recv().await, Ok(AuthResult::Accepted { .. })) { warn!("Web user approval failed"); return fail(&mut self).await; } } else { return fail(&mut self).await; } } AuthResult::Rejected => return fail(&mut self).await, } } } AuthSelector::Ticket { secret } => { match authorize_ticket(&self.services.db, &secret) .await .map_err(PostgresError::other)? { Some((ticket, user_info)) => { info!("Authorized for {} with a ticket", ticket.target); consume_ticket(&self.services.db, &ticket.id) .await .map_err(PostgresError::other)?; self.stream .push(pgwire::messages::startup::Authentication::Ok)?; self.run_authorized(startup, user_info, ticket.target).await } _ => fail(&mut self).await, } } } } async fn run_authorized( mut self, startup: pgwire::messages::startup::Startup, user_info: AuthStateUserInfo, target_name: String, ) -> Result<(), PostgresError> { self.stream.flush().await?; let target = { self.services .config_provider .lock() .await .list_targets() .await? .iter() .filter_map(|t| match t.options { TargetOptions::Postgres(ref options) => Some((t, options)), _ => None, }) .find(|(t, _)| t.name == target_name) .map(|(t, opt)| (t.clone(), opt.clone())) }; let Some((target, postgres_options)) = target else { warn!("Selected target not found"); self.send_error_response( "0W001".into(), format!("Warpgate target {target_name} not found"), ) .await?; return Ok(()); }; { let handle = self.server_handle.lock().await; handle.set_user_info(user_info).await?; handle.set_target(&target).await?; } self.run_authorized_inner(startup, postgres_options).await } async fn send_error_response( &mut self, code: String, message: String, ) -> Result<(), PostgresError> { let error_info = ErrorInfo::new("FATAL".to_owned(), code, message); self.stream .push(pgwire::messages::response::ErrorResponse::from(error_info))?; self.stream.flush().await?; Ok(()) } async fn run_authorized_inner( mut self, startup: pgwire::messages::startup::Startup, options: TargetPostgresOptions, ) -> Result<(), PostgresError> { let mut client = match PostgresClient::connect( &options, ConnectionOptions { protocol_number_major: startup.protocol_number_major, protocol_number_minor: startup.protocol_number_minor, parameters: startup.parameters, }, ) .await { Err(error) => { self.send_error_response( "0W002".into(), "Warpgate target connection failed".into(), ) .await?; Err(error) } x => x, }?; // Parse idle timeout from config let idle_timeout = options .idle_timeout .as_ref() .and_then(|s| { let trimmed = s.trim(); if trimmed.is_empty() { None } else { humantime::parse_duration(trimmed) .map_err(|e| { warn!( timeout_string = %trimmed, error = %e, "Invalid idle_timeout value, falling back to default" ); e }) .ok() } }) .unwrap_or(Duration::from_secs(60 * 10)); // Default 10 minutes if idle_timeout.as_secs() > 0 { info!( idle_timeout_seconds = idle_timeout.as_secs(), "Using configured idle timeout for session" ); } let mut last_activity = std::time::Instant::now(); let check_interval = Duration::from_secs(5); // Check idle timeout every 5 seconds loop { let elapsed = last_activity.elapsed(); if elapsed > idle_timeout { info!( idle_seconds = elapsed.as_secs(), timeout_seconds = idle_timeout.as_secs(), "Session idle timeout exceeded, closing connection" ); self.send_error_response( "57P01".into(), format!( "Session idle for {} exceeded configured timeout of {}. Please reconnect.", humantime::format_duration(elapsed), humantime::format_duration(idle_timeout) ), ) .await?; break; } let remaining_timeout = idle_timeout - elapsed; let select_timeout = remaining_timeout.min(check_interval); tokio::select! { c_to_s = time::timeout(select_timeout, self.stream.recv::()) => { match c_to_s { Ok(Ok(Some(msg))) => { last_activity = std::time::Instant::now(); // Update activity on client message self.maybe_log_client_msg(&msg.0); client.send(msg).await?; } Ok(Ok(None)) => { break } Ok(Err(err)) => { error!(error=%err, "Error receiving message"); break } Err(_) => { // Timeout - check if we've exceeded idle timeout continue; } }; }, s_to_c = client.recv() => { match s_to_c { Ok(Some(msg)) => { last_activity = std::time::Instant::now(); // Update activity on server message self.maybe_log_server_msg(&msg.0); self.stream.push(msg)?; self.stream.flush().await?; } Ok(None) => { break } Err(err) => { error!(error=%err, "Error receiving message"); break } }; } }; } Ok(()) } fn maybe_log_client_msg(&self, msg: &PgWireFrontendMessage) { debug!(?msg, "C->S message"); match msg { PgWireFrontendMessage::Parse(query) => { info!(query_name=?query.name, query=query.query, "Preparing query"); } PgWireFrontendMessage::Execute(query) => { info!(query_name=?query.name, "Executing prepared query"); } PgWireFrontendMessage::Query(query) => { info!(query=%query.query, "Query"); } _ => (), } } fn maybe_log_server_msg(&self, msg: &PgWireBackendMessage) { debug!(?msg, "S->C message"); if let PgWireBackendMessage::ErrorResponse(error) = msg { info!(?error, "PostgreSQL error"); } } } ================================================ FILE: warpgate-protocol-postgres/src/session_handle.rs ================================================ use tokio::sync::mpsc; use warpgate_core::SessionHandle; pub struct PostgresSessionHandle { abort_tx: mpsc::UnboundedSender<()>, } impl PostgresSessionHandle { pub fn new() -> (Self, mpsc::UnboundedReceiver<()>) { let (abort_tx, abort_rx) = mpsc::unbounded_channel(); (PostgresSessionHandle { abort_tx }, abort_rx) } } impl SessionHandle for PostgresSessionHandle { fn close(&mut self) { let _ = self.abort_tx.send(()); } } ================================================ FILE: warpgate-protocol-postgres/src/stream.rs ================================================ use std::fmt::Debug; use bytes::BytesMut; use pgwire::error::{PgWireError, PgWireResult}; use pgwire::messages::{PgWireBackendMessage, PgWireFrontendMessage}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tracing::*; use warpgate_tls::{MaybeTlsStream, MaybeTlsStreamError, UpgradableStream}; #[derive(thiserror::Error, Debug)] pub enum PostgresStreamError { #[error("decode: {0}")] Decode(#[from] PgWireError), #[error("I/O: {0}")] Io(#[from] std::io::Error), } pub(crate) trait PostgresEncode { fn encode(&self, buf: &mut BytesMut) -> PgWireResult<()> where Self: Sized; } pub(crate) trait PostgresDecode { fn decode(buf: &mut BytesMut) -> PgWireResult> where Self: Sized; } #[derive(Debug)] pub(crate) enum PgWireStartupOrSslRequest { Startup(pgwire::messages::startup::Startup), SslRequest(pgwire::messages::startup::SslRequest), } impl PostgresDecode for PgWireStartupOrSslRequest { fn decode(buf: &mut BytesMut) -> PgWireResult> { if let Ok(Some(result)) = pgwire::messages::startup::SslRequest::decode(buf) { return Ok(Some(Self::SslRequest(result))); } pgwire::messages::startup::Startup::decode(buf).map(|x| x.map(Self::Startup)) } } #[derive(Debug)] pub(crate) struct PgWireGenericFrontendMessage(pub PgWireFrontendMessage); #[derive(Debug)] pub(crate) struct PgWireGenericBackendMessage(pub PgWireBackendMessage); impl PostgresDecode for PgWireGenericFrontendMessage { fn decode(buf: &mut BytesMut) -> PgWireResult> { PgWireFrontendMessage::decode(buf).map(|x| x.map(PgWireGenericFrontendMessage)) } } impl PostgresDecode for PgWireGenericBackendMessage { fn decode(buf: &mut BytesMut) -> PgWireResult> { PgWireBackendMessage::decode(buf).map(|x| x.map(PgWireGenericBackendMessage)) } } impl PostgresDecode for T { fn decode(buf: &mut BytesMut) -> PgWireResult> { T::decode(buf) } } impl PostgresEncode for PgWireGenericFrontendMessage { fn encode(&self, buf: &mut BytesMut) -> PgWireResult<()> { self.0.encode(buf) } } impl PostgresEncode for PgWireGenericBackendMessage { fn encode(&self, buf: &mut BytesMut) -> PgWireResult<()> { self.0.encode(buf) } } impl PostgresEncode for T { fn encode(&self, buf: &mut BytesMut) -> PgWireResult<()> { self.encode(buf) } } pub(crate) struct PostgresStream where S: UpgradableStream, S: AsyncRead + AsyncWrite + Send + Unpin, TS: AsyncRead + AsyncWrite + Unpin, { stream: MaybeTlsStream, inbound_buffer: BytesMut, outbound_buffer: BytesMut, } impl PostgresStream where S: UpgradableStream, S: AsyncRead + AsyncWrite + Send + Unpin, TS: AsyncRead + AsyncWrite + Unpin, { pub fn new(stream: S) -> Self { Self { stream: MaybeTlsStream::new(stream), inbound_buffer: BytesMut::new(), outbound_buffer: BytesMut::new(), } } pub fn push( &mut self, message: M, ) -> Result<(), PostgresStreamError> { trace!(?message, "sending"); message.encode(&mut self.outbound_buffer)?; Ok(()) } pub async fn flush(&mut self) -> std::io::Result<()> { self.stream.write_all(&self.outbound_buffer[..]).await?; self.outbound_buffer = BytesMut::new(); self.stream.flush().await?; Ok(()) } pub(crate) async fn recv( &mut self, ) -> Result, PostgresStreamError> { loop { if let Some(message) = T::decode(&mut self.inbound_buffer)? { trace!(?message, "received"); return Ok(Some(message)); }; let read_bytes = self.stream.read_buf(&mut self.inbound_buffer).await?; if read_bytes == 0 { return Ok(None); } } } pub(crate) async fn upgrade( mut self, config: >::UpgradeConfig, ) -> Result { self.stream = self.stream.upgrade(config).await?; Ok(self) } } ================================================ FILE: warpgate-protocol-ssh/Cargo.toml ================================================ [package] edition = "2021" license = "Apache-2.0" name = "warpgate-protocol-ssh" version = "0.22.0" [dependencies] ansi_term = { version = "0.12", default-features = false } anyhow.workspace = true async-trait = { version = "0.1", default-features = false } bimap = { version = "0.6", default-features = false, features = ["std"] } bytes.workspace = true dialoguer.workspace = true curve25519-dalek = { version = "4.0.0", default-features = false } # pin due to build fail on x86 ed25519-dalek = { version = "2.0.0", default-features = false } # pin due to build fail on x86 in 2.1 futures.workspace = true russh.workspace = true serde.workspace = true sea-orm.workspace = true thiserror.workspace = true time = { version = "0.3", default-features = false } tokio.workspace = true tracing.workspace = true uuid.workspace = true warpgate-common = { version = "*", path = "../warpgate-common", default-features = false } warpgate-core = { version = "*", path = "../warpgate-core", default-features = false } warpgate-db-entities = { version = "*", path = "../warpgate-db-entities", default-features = false } zeroize = { version = "^1.5", default-features = false } ================================================ FILE: warpgate-protocol-ssh/src/client/channel_direct_tcpip.rs ================================================ use anyhow::Result; use bytes::Bytes; use russh::client::Msg; use russh::Channel; use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; use tracing::*; use uuid::Uuid; use warpgate_common::SessionId; use super::error::SshClientError; use crate::{ChannelOperation, RCEvent}; pub struct DirectTCPIPChannel { client_channel: Channel, channel_id: Uuid, ops_rx: UnboundedReceiver, events_tx: UnboundedSender, session_id: SessionId, } impl DirectTCPIPChannel { pub fn new( client_channel: Channel, channel_id: Uuid, ops_rx: UnboundedReceiver, events_tx: UnboundedSender, session_id: SessionId, ) -> Self { DirectTCPIPChannel { client_channel, channel_id, ops_rx, events_tx, session_id, } } pub async fn run(mut self) -> Result<(), SshClientError> { loop { tokio::select! { incoming_data = self.ops_rx.recv() => { match incoming_data { Some(ChannelOperation::Data(data)) => { self.client_channel.data(&*data).await?; } Some(ChannelOperation::Eof) => { self.client_channel.eof().await?; }, Some(ChannelOperation::Close) => break, None => break, Some(operation) => { warn!(client_channel=%self.channel_id, ?operation, session=%self.session_id, "unexpected client_channel operation"); } } } channel_event = self.client_channel.wait() => { match channel_event { Some(russh::ChannelMsg::Data { data }) => { let bytes: &[u8] = &data; self.events_tx.send(RCEvent::Output( self.channel_id, Bytes::from(bytes.to_vec()), )).map_err(|_| SshClientError::MpscError)?; } Some(russh::ChannelMsg::Close) => { self.events_tx.send(RCEvent::Close(self.channel_id)).map_err(|_| SshClientError::MpscError)?; }, Some(russh::ChannelMsg::Success) => { self.events_tx.send(RCEvent::Success(self.channel_id)).map_err(|_| SshClientError::MpscError)?; }, Some(russh::ChannelMsg::Eof) => { self.events_tx.send(RCEvent::Eof(self.channel_id)).map_err(|_| SshClientError::MpscError)?; } None => { self.events_tx.send(RCEvent::Close(self.channel_id)).map_err(|_| SshClientError::MpscError)?; break }, Some(operation) => { warn!(client_channel=%self.channel_id, ?operation, session=%self.session_id, "unexpected client_channel operation"); } } } } } Ok(()) } } impl Drop for DirectTCPIPChannel { fn drop(&mut self) { info!(client_channel=%self.channel_id, session=%self.session_id, "Closed"); } } ================================================ FILE: warpgate-protocol-ssh/src/client/channel_session.rs ================================================ use anyhow::Result; use bytes::Bytes; use russh::client::Msg; use russh::Channel; use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; use tracing::*; use uuid::Uuid; use warpgate_common::SessionId; use super::error::SshClientError; use crate::{ChannelOperation, RCEvent}; pub struct SessionChannel { client_channel: Channel, channel_id: Uuid, ops_rx: UnboundedReceiver, events_tx: UnboundedSender, session_id: SessionId, closed: bool, } impl SessionChannel { pub fn new( client_channel: Channel, channel_id: Uuid, ops_rx: UnboundedReceiver, events_tx: UnboundedSender, session_id: SessionId, ) -> Self { SessionChannel { client_channel, channel_id, ops_rx, events_tx, session_id, closed: false, } } pub async fn run(mut self) -> Result<(), SshClientError> { loop { tokio::select! { incoming_data = self.ops_rx.recv() => { match incoming_data { Some(ChannelOperation::Data(data)) => { self.client_channel.data(&*data).await?; } Some(ChannelOperation::ExtendedData { ext, data }) => { self.client_channel.extended_data(ext, &*data).await?; } Some(ChannelOperation::RequestPty(request)) => { self.client_channel.request_pty( true, &request.term, request.col_width, request.row_height, request.pix_width, request.pix_height, &request.modes, ).await?; } Some(ChannelOperation::ResizePty(request)) => { self.client_channel.window_change( request.col_width, request.row_height, request.pix_width, request.pix_height, ).await?; }, Some(ChannelOperation::RequestShell) => { self.client_channel.request_shell(true).await?; }, Some(ChannelOperation::RequestEnv(name, value)) => { self.client_channel.set_env(false, name, value).await?; }, Some(ChannelOperation::RequestExec(command)) => { self.client_channel.exec(false, command).await?; }, Some(ChannelOperation::RequestSubsystem(name)) => { self.client_channel.request_subsystem(false, &name).await?; }, Some(ChannelOperation::Eof) => { self.client_channel.eof().await?; }, Some(ChannelOperation::Signal(signal)) => { self.client_channel.signal(signal).await?; }, Some(ChannelOperation::OpenShell) => unreachable!(), Some(ChannelOperation::OpenDirectTCPIP { .. }) => unreachable!(), Some(ChannelOperation::OpenDirectStreamlocal { .. }) => unreachable!(), Some(ChannelOperation::OpenX11 { .. }) => unreachable!(), Some(ChannelOperation::RequestX11(request)) => { self.client_channel.request_x11( true, request.single_conection, request.x11_auth_protocol, request.x11_auth_cookie, request.x11_screen_number, ).await?; }, Some(ChannelOperation::AgentForward) => { self.client_channel.agent_forward( true, ).await?; } Some(ChannelOperation::Close) => break, None => break, } } channel_event = self.client_channel.wait() => { match channel_event { Some(russh::ChannelMsg::Data { data }) => { let bytes: &[u8] = &data; debug!("channel data: {bytes:?}"); self.events_tx.send(RCEvent::Output( self.channel_id, Bytes::from(bytes.to_vec()), )).map_err(|_| SshClientError::MpscError)?; } Some(russh::ChannelMsg::Close) => { break; }, Some(russh::ChannelMsg::Success) => { self.events_tx.send(RCEvent::Success(self.channel_id)).map_err(|_| SshClientError::MpscError)?; }, Some(russh::ChannelMsg::Failure) => { self.events_tx.send(RCEvent::ChannelFailure(self.channel_id)).map_err(|_| SshClientError::MpscError)?; }, Some(russh::ChannelMsg::Eof) => { self.events_tx.send(RCEvent::Eof(self.channel_id)).map_err(|_| SshClientError::MpscError)?; } Some(russh::ChannelMsg::ExitStatus { exit_status }) => { self.events_tx.send(RCEvent::ExitStatus(self.channel_id, exit_status)).map_err(|_| SshClientError::MpscError)?; } Some(russh::ChannelMsg::WindowAdjusted { .. }) => { }, Some(russh::ChannelMsg::ExitSignal { core_dumped, error_message, lang_tag, signal_name }) => { self.events_tx.send(RCEvent::ExitSignal { channel: self.channel_id, core_dumped, error_message, lang_tag, signal_name }).map_err(|_| SshClientError::MpscError)?; }, Some(russh::ChannelMsg::XonXoff { client_can_do: _ }) => { } Some(russh::ChannelMsg::ExtendedData { data, ext }) => { let data: &[u8] = &data; self.events_tx.send(RCEvent::ExtendedData { channel: self.channel_id, data: Bytes::from(data.to_vec()), ext, }).map_err(|_| SshClientError::MpscError)?; } Some(msg) => { warn!("unhandled channel message: {:?}", msg); } None => { break }, } } } } self.close()?; Ok(()) } fn close(&mut self) -> Result<(), SshClientError> { if !self.closed { let _ = self .events_tx .send(RCEvent::Close(self.channel_id)) .map_err(|_| SshClientError::MpscError); self.closed = true; } Ok(()) } } impl Drop for SessionChannel { fn drop(&mut self) { let _ = self.close(); info!(channel=%self.channel_id, session=%self.session_id, "Closed"); } } ================================================ FILE: warpgate-protocol-ssh/src/client/error.rs ================================================ use std::error::Error; use warpgate_common::WarpgateError; #[derive(thiserror::Error, Debug)] pub enum SshClientError { #[error("mpsc error")] MpscError, #[error("russh error: {0}")] Russh(#[from] russh::Error), #[error(transparent)] Warpgate(#[from] WarpgateError), #[error(transparent)] Other(Box), } impl SshClientError { pub fn other(err: E) -> Self { Self::Other(Box::new(err)) } } ================================================ FILE: warpgate-protocol-ssh/src/client/handler.rs ================================================ use russh::client::{Msg, Session}; use russh::keys::{PublicKey, PublicKeyBase64}; use russh::Channel; use tokio::sync::mpsc::UnboundedSender; use tokio::sync::oneshot; use tracing::*; use warpgate_common::{SessionId, TargetSSHOptions}; use warpgate_core::Services; use crate::known_hosts::{KnownHostValidationResult, KnownHosts}; use crate::{ConnectionError, ForwardedStreamlocalParams, ForwardedTcpIpParams}; #[derive(Debug)] pub enum ClientHandlerEvent { HostKeyReceived(PublicKey), HostKeyUnknown(PublicKey, oneshot::Sender), ForwardedTcpIp(Channel, ForwardedTcpIpParams), ForwardedStreamlocal(Channel, ForwardedStreamlocalParams), ForwardedAgent(Channel), X11(Channel, String, u32), Disconnect, } pub struct ClientHandler { pub ssh_options: TargetSSHOptions, pub event_tx: UnboundedSender, pub services: Services, pub session_id: SessionId, } #[derive(Debug, thiserror::Error)] pub enum ClientHandlerError { #[error("Connection error")] ConnectionError(ConnectionError), #[error("SSH")] Ssh(#[from] russh::Error), #[error("Internal error")] Internal, } impl russh::client::Handler for ClientHandler { type Error = ClientHandlerError; async fn check_server_key( &mut self, server_public_key: &PublicKey, ) -> Result { let mut known_hosts = KnownHosts::new(&self.services.db); self.event_tx .send(ClientHandlerEvent::HostKeyReceived( server_public_key.clone(), )) .map_err(|_| ClientHandlerError::ConnectionError(ConnectionError::Internal))?; match known_hosts .validate( &self.ssh_options.host, self.ssh_options.port, server_public_key, ) .await { Ok(KnownHostValidationResult::Valid) => Ok(true), Ok(KnownHostValidationResult::Invalid { key_type, key_base64, }) => { warn!(session=%self.session_id, "Host key is invalid!"); Err(ClientHandlerError::ConnectionError( ConnectionError::HostKeyMismatch { received_key_type: server_public_key.algorithm(), received_key_base64: server_public_key.public_key_base64(), known_key_type: key_type, known_key_base64: key_base64, }, )) } Ok(KnownHostValidationResult::Unknown) => { warn!(session=%self.session_id, "Host key is unknown"); let (tx, rx) = oneshot::channel(); self.event_tx .send(ClientHandlerEvent::HostKeyUnknown( server_public_key.clone(), tx, )) .map_err(|_| ClientHandlerError::Internal)?; let accepted = rx.await.map_err(|_| ClientHandlerError::Internal)?; if accepted { if let Err(error) = known_hosts .trust( &self.ssh_options.host, self.ssh_options.port, server_public_key, ) .await { error!(?error, session=%self.session_id, "Failed to save host key"); } Ok(true) } else { Ok(false) } } Err(error) => { error!(?error, session=%self.session_id, "Failed to verify the host key"); Err(ClientHandlerError::Internal) } } } async fn server_channel_open_forwarded_tcpip( &mut self, channel: Channel, connected_address: &str, connected_port: u32, originator_address: &str, originator_port: u32, __session: &mut Session, ) -> Result<(), Self::Error> { let connected_address = connected_address.to_string(); let originator_address = originator_address.to_string(); let _ = self.event_tx.send(ClientHandlerEvent::ForwardedTcpIp( channel, ForwardedTcpIpParams { connected_address, connected_port, originator_address, originator_port, }, )); Ok(()) } async fn server_channel_open_x11( &mut self, channel: Channel, originator_address: &str, originator_port: u32, _session: &mut Session, ) -> Result<(), Self::Error> { let originator_address = originator_address.to_string(); let _ = self.event_tx.send(ClientHandlerEvent::X11( channel, originator_address, originator_port, )); Ok(()) } async fn server_channel_open_forwarded_streamlocal( &mut self, channel: Channel, socket_path: &str, _session: &mut Session, ) -> Result<(), Self::Error> { let socket_path = socket_path.to_string(); let _ = self.event_tx.send(ClientHandlerEvent::ForwardedStreamlocal( channel, ForwardedStreamlocalParams { socket_path }, )); Ok(()) } async fn server_channel_open_agent_forward( &mut self, channel: Channel, _session: &mut Session, ) -> Result<(), Self::Error> { let _ = self .event_tx .send(ClientHandlerEvent::ForwardedAgent(channel)); Ok(()) } } impl Drop for ClientHandler { fn drop(&mut self) { let _ = self.event_tx.send(ClientHandlerEvent::Disconnect); debug!(session=%self.session_id, "Dropped"); } } ================================================ FILE: warpgate-protocol-ssh/src/client/mod.rs ================================================ mod channel_direct_tcpip; mod channel_session; mod error; mod handler; use std::borrow::Cow; use std::collections::HashMap; use std::io; use std::net::ToSocketAddrs; use std::sync::Arc; use anyhow::Result; use bytes::Bytes; use channel_direct_tcpip::DirectTCPIPChannel; use channel_session::SessionChannel; pub use error::SshClientError; use futures::pin_mut; use handler::ClientHandler; use russh::client::{AuthResult, Handle, KeyboardInteractiveAuthResponse}; use russh::keys::{PrivateKeyWithHashAlg, PublicKey}; use russh::{kex, MethodKind, Preferred, Sig}; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; use tokio::sync::{oneshot, Mutex}; use tokio::task::JoinHandle; use tracing::*; use uuid::Uuid; use warpgate_common::{SSHTargetAuth, SessionId, TargetSSHOptions}; use warpgate_core::Services; use self::handler::ClientHandlerEvent; use super::{ChannelOperation, DirectTCPIPParams}; use crate::client::handler::ClientHandlerError; use crate::{load_keys, ForwardedStreamlocalParams, ForwardedTcpIpParams}; #[derive(Debug, thiserror::Error)] pub enum ConnectionError { #[error("Host key mismatch")] HostKeyMismatch { received_key_type: russh::keys::Algorithm, received_key_base64: String, known_key_type: String, known_key_base64: String, }, #[error(transparent)] Io(#[from] std::io::Error), #[error(transparent)] Key(#[from] russh::keys::Error), #[error(transparent)] Ssh(#[from] russh::Error), #[error("Could not resolve address")] Resolve, #[error("Internal error")] Internal, #[error("Aborted")] Aborted, #[error("Authentication failed")] Authentication, } #[derive(Debug)] pub enum RCEvent { State(RCState), Output(Uuid, Bytes), Success(Uuid), ChannelFailure(Uuid), Eof(Uuid), Close(Uuid), Error(anyhow::Error), ExitStatus(Uuid, u32), ExitSignal { channel: Uuid, signal_name: Sig, core_dumped: bool, error_message: String, lang_tag: String, }, ExtendedData { channel: Uuid, data: Bytes, ext: u32, }, ConnectionError(ConnectionError), // ForwardedTCPIP(Uuid, DirectTCPIPParams), Done, HostKeyReceived(PublicKey), HostKeyUnknown(PublicKey, oneshot::Sender), ForwardedTcpIp(Uuid, ForwardedTcpIpParams), ForwardedStreamlocal(Uuid, ForwardedStreamlocalParams), ForwardedAgent(Uuid), X11(Uuid, String, u32), } pub type RCCommandReply = oneshot::Sender>; #[derive(Clone, Debug)] pub enum RCCommand { Connect(TargetSSHOptions), Channel(Uuid, ChannelOperation), ForwardTCPIP(String, u32), CancelTCPIPForward(String, u32), StreamlocalForward(String), CancelStreamlocalForward(String), Disconnect, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum RCState { NotInitialized, Connecting, Connected, Disconnected, } #[derive(Debug)] enum InnerEvent { RCCommand(RCCommand, Option), ClientHandlerEvent(ClientHandlerEvent), } pub struct RemoteClient { id: SessionId, tx: UnboundedSender, session: Option>>>, channel_pipes: Arc>>>, pending_ops: Vec<(Uuid, ChannelOperation)>, pending_forwards: Vec<(String, u32)>, pending_streamlocal_forwards: Vec, state: RCState, abort_rx: UnboundedReceiver<()>, inner_event_rx: UnboundedReceiver, inner_event_tx: UnboundedSender, child_tasks: Vec>>, services: Services, } pub struct RemoteClientHandles { pub event_rx: UnboundedReceiver, pub command_tx: UnboundedSender<(RCCommand, Option)>, pub abort_tx: UnboundedSender<()>, } impl RemoteClient { pub fn create(id: SessionId, services: Services) -> io::Result { let (event_tx, event_rx) = unbounded_channel(); let (command_tx, mut command_rx) = unbounded_channel(); let (abort_tx, abort_rx) = unbounded_channel(); let (inner_event_tx, inner_event_rx) = unbounded_channel(); let this = Self { id, tx: event_tx, session: None, channel_pipes: Arc::new(Mutex::new(HashMap::new())), pending_ops: vec![], pending_forwards: vec![], pending_streamlocal_forwards: vec![], state: RCState::NotInitialized, inner_event_rx, inner_event_tx: inner_event_tx.clone(), child_tasks: vec![], services, abort_rx, }; tokio::spawn( { async move { while let Some((e, response)) = command_rx.recv().await { inner_event_tx.send(InnerEvent::RCCommand(e, response))? } Ok::<(), anyhow::Error>(()) } } .instrument(Span::current()), ); this.start()?; Ok(RemoteClientHandles { event_rx, command_tx, abort_tx, }) } fn set_disconnected(&mut self) { self.session = None; for (id, op) in self.pending_ops.drain(..) { if let ChannelOperation::OpenShell = op { let _ = self.tx.send(RCEvent::Close(id)); } if let ChannelOperation::OpenDirectTCPIP { .. } = op { let _ = self.tx.send(RCEvent::Close(id)); } } let _ = self.set_state(RCState::Disconnected); let _ = self.tx.send(RCEvent::Done); } fn set_state(&mut self, state: RCState) -> Result<(), SshClientError> { self.state = state.clone(); self.tx .send(RCEvent::State(state)) .map_err(|_| SshClientError::MpscError)?; Ok(()) } // fn map_channel(&self, ch: &ChannelId) -> Result { // self.channel_map // .get_by_left(ch) // .cloned() // .ok_or_else(|| anyhow::anyhow!("Channel not known")) // } // fn map_channel_reverse(&self, ch: &Uuid) -> Result { // self.channel_map // .get_by_right(ch) // .cloned() // .ok_or_else(|| anyhow::anyhow!("Channel not known")) // } async fn apply_channel_op( &mut self, channel_id: Uuid, op: ChannelOperation, ) -> Result<(), SshClientError> { if self.state != RCState::Connected { self.pending_ops.push((channel_id, op)); return Ok(()); } match op { ChannelOperation::OpenShell => { self.open_shell(channel_id).await?; } ChannelOperation::OpenDirectTCPIP(params) => { self.open_direct_tcpip(channel_id, params).await?; } ChannelOperation::OpenDirectStreamlocal(path) => { self.open_direct_streamlocal(channel_id, path).await?; } op => { let mut channel_pipes = self.channel_pipes.lock().await; match channel_pipes.get(&channel_id) { Some(tx) => { if tx.send(op).is_err() { channel_pipes.remove(&channel_id); } } None => debug!(channel=%channel_id, "operation for unknown channel"), } } } Ok(()) } pub fn start(mut self) -> io::Result>> { let name = format!("SSH {} client commands", self.id); tokio::task::Builder::new().name(&name).spawn( async move { async { loop { tokio::select! { Some(event) = self.inner_event_rx.recv() => { debug!(event=?event, "event"); if self.handle_event(event).await? { break } } Some(_) = self.abort_rx.recv() => { debug!("Abort requested"); self.disconnect().await; break } }; } Ok::<(), anyhow::Error>(()) } .await .map_err(|error| { error!(?error, "error in command loop"); let err = anyhow::anyhow!("Error in command loop: {error}"); let _ = self.tx.send(RCEvent::Error(error)); err })?; info!("Client session closed"); Ok::<(), anyhow::Error>(()) } .instrument(Span::current()), ) } async fn handle_event(&mut self, event: InnerEvent) -> Result { match event { InnerEvent::RCCommand(cmd, reply) => { let result = self.handle_command(cmd).await; let brk = matches!(result, Ok(true)); if let Some(reply) = reply { let _ = reply.send(result.map(|_| ())); } return Ok(brk); } InnerEvent::ClientHandlerEvent(client_event) => { debug!("Client handler event: {:?}", client_event); match client_event { ClientHandlerEvent::Disconnect => { self._on_disconnect().await?; } ClientHandlerEvent::ForwardedTcpIp(channel, params) => { info!("New forwarded connection: {params:?}"); let id = self.setup_server_initiated_channel(channel).await?; let _ = self.tx.send(RCEvent::ForwardedTcpIp(id, params)); } ClientHandlerEvent::ForwardedStreamlocal(channel, params) => { info!("New forwarded socket connection: {params:?}"); let id = self.setup_server_initiated_channel(channel).await?; let _ = self.tx.send(RCEvent::ForwardedStreamlocal(id, params)); } ClientHandlerEvent::ForwardedAgent(channel) => { info!("New forwarded agent connection"); let id = self.setup_server_initiated_channel(channel).await?; let _ = self.tx.send(RCEvent::ForwardedAgent(id)); } ClientHandlerEvent::X11(channel, originator_address, originator_port) => { info!("New X11 connection from {originator_address}:{originator_port:?}"); let id = self.setup_server_initiated_channel(channel).await?; let _ = self .tx .send(RCEvent::X11(id, originator_address, originator_port)); } event => { error!(?event, "Unhandled client handler event"); } } } } Ok(false) } async fn setup_server_initiated_channel( &mut self, channel: russh::Channel, ) -> Result { let id = Uuid::new_v4(); let (tx, rx) = unbounded_channel(); self.channel_pipes.lock().await.insert(id, tx); let session_channel = SessionChannel::new(channel, id, rx, self.tx.clone(), self.id); self.child_tasks.push( tokio::task::Builder::new() .name(&format!("SSH {} {:?} ops", self.id, id)) .spawn(session_channel.run())?, ); Ok(id) } async fn handle_command(&mut self, cmd: RCCommand) -> Result { match cmd { RCCommand::Connect(options) => match self.connect(options).await { Ok(_) => { self.set_state(RCState::Connected) .map_err(SshClientError::other)?; let ops = self.pending_ops.drain(..).collect::>(); for (id, op) in ops { self.apply_channel_op(id, op).await?; } let forwards = self.pending_forwards.drain(..).collect::>(); for (address, port) in forwards { self.tcpip_forward(address, port).await?; } let forwards = self .pending_streamlocal_forwards .drain(..) .collect::>(); for socket_path in forwards { self.streamlocal_forward(socket_path).await?; } } Err(e) => { debug!("Connect error: {}", e); let _ = self.tx.send(RCEvent::ConnectionError(e)); // Allow some time for the SessionServer to process the ConnectionError and print a message to the terminal // before closing the session. If we don't wait, the session might close too quickly and the user won't see the error. tokio::time::sleep(std::time::Duration::from_millis(100)).await; self.set_disconnected(); return Ok(true); } }, RCCommand::Channel(ch, op) => { self.apply_channel_op(ch, op).await?; } RCCommand::ForwardTCPIP(address, port) => { self.tcpip_forward(address, port).await?; } RCCommand::CancelTCPIPForward(address, port) => { self.cancel_tcpip_forward(address, port).await?; } RCCommand::StreamlocalForward(socket_path) => { self.streamlocal_forward(socket_path).await?; } RCCommand::CancelStreamlocalForward(socket_path) => { self.cancel_streamlocal_forward(socket_path).await?; } RCCommand::Disconnect => { self.disconnect().await; return Ok(true); } } Ok(false) } async fn connect(&mut self, ssh_options: TargetSSHOptions) -> Result<(), ConnectionError> { let address_str = format!("{}:{}", ssh_options.host, ssh_options.port); let address = match address_str .to_socket_addrs() .map_err(ConnectionError::Io) .and_then(|mut x| x.next().ok_or(ConnectionError::Resolve)) { Ok(address) => address, Err(error) => { error!(?error, address=%address_str, "Cannot resolve target address"); self.set_disconnected(); return Err(error); } }; info!(?address, username = &ssh_options.username[..], "Connecting"); let algos = if ssh_options.allow_insecure_algos.unwrap_or(false) { Preferred { kex: Cow::Borrowed(&[ kex::MLKEM768X25519_SHA256, kex::CURVE25519, kex::CURVE25519_PRE_RFC_8731, kex::ECDH_SHA2_NISTP256, kex::ECDH_SHA2_NISTP384, kex::ECDH_SHA2_NISTP521, kex::DH_G16_SHA512, kex::DH_G14_SHA256, // non-default kex::DH_GEX_SHA256, kex::DH_G1_SHA1, // non-default kex::EXTENSION_SUPPORT_AS_CLIENT, kex::EXTENSION_SUPPORT_AS_SERVER, kex::EXTENSION_OPENSSH_STRICT_KEX_AS_CLIENT, kex::EXTENSION_OPENSSH_STRICT_KEX_AS_SERVER, ]), key: Cow::Borrowed(&[ russh::keys::Algorithm::Ed25519, russh::keys::Algorithm::Ecdsa { curve: russh::keys::EcdsaCurve::NistP256, }, russh::keys::Algorithm::Ecdsa { curve: russh::keys::EcdsaCurve::NistP384, }, russh::keys::Algorithm::Ecdsa { curve: russh::keys::EcdsaCurve::NistP521, }, russh::keys::Algorithm::Rsa { hash: Some(russh::keys::HashAlg::Sha256), }, russh::keys::Algorithm::Rsa { hash: Some(russh::keys::HashAlg::Sha512), }, russh::keys::Algorithm::Rsa { hash: None }, ]), cipher: Cow::Borrowed(&[ russh::cipher::CHACHA20_POLY1305, russh::cipher::AES_256_GCM, russh::cipher::AES_256_CTR, russh::cipher::AES_256_CBC, russh::cipher::AES_192_CTR, russh::cipher::AES_192_CBC, russh::cipher::AES_128_CTR, russh::cipher::AES_128_CBC, russh::cipher::TRIPLE_DES_CBC, ]), ..<_>::default() } } else { Preferred::default() }; let mut config = russh::client::Config { preferred: algos, nodelay: true, ..Default::default() }; if ssh_options.allow_insecure_algos.unwrap_or(false) { if let Ok(gex) = russh::client::GexParams::new(2048, 2048, 8192) { config.gex = gex; } } let config = Arc::new(config); let (event_tx, mut event_rx) = unbounded_channel(); let handler = ClientHandler { ssh_options: ssh_options.clone(), event_tx, services: self.services.clone(), session_id: self.id, }; let fut_connect = russh::client::connect(config, address, handler); pin_mut!(fut_connect); loop { tokio::select! { Some(event) = event_rx.recv() => { match event { ClientHandlerEvent::HostKeyReceived(key) => { self.tx.send(RCEvent::HostKeyReceived(key)).map_err(|_| ConnectionError::Internal)?; } ClientHandlerEvent::HostKeyUnknown(key, reply) => { self.tx.send(RCEvent::HostKeyUnknown(key, reply)).map_err(|_| ConnectionError::Internal)?; } _ => {} } } Some(_) = self.abort_rx.recv() => { info!("Abort requested"); self.set_disconnected(); return Err(ConnectionError::Aborted) } session = &mut fut_connect => { let mut session = match session { Ok(session) => session, Err(error) => { let connection_error = match error { ClientHandlerError::ConnectionError(e) => e, ClientHandlerError::Ssh(e) => ConnectionError::Ssh(e), ClientHandlerError::Internal => ConnectionError::Internal, }; error!(error=?connection_error, "Connection error"); return Err(connection_error); } }; let mut auth_result = false; let mut auth_error_msg: Option = None; match ssh_options.auth { SSHTargetAuth::Password(auth) => { let response = session .authenticate_password( ssh_options.username.clone(), auth.password.expose_secret() ) .await?; auth_result = self._handle_auth_result( &mut session, ssh_options.username.clone(), response ).await.unwrap_or(false); if auth_result { debug!(username=&ssh_options.username[..], "Authenticated with password"); } else { auth_error_msg = Some("Password authentication was rejected by the SSH target".to_string()); } } SSHTargetAuth::PublicKey(_) => { let best_hash = session.best_supported_rsa_hash().await?.flatten(); #[allow(clippy::explicit_auto_deref)] let keys = load_keys( &*self.services.config.lock().await, &self.services.global_params, "client" )?; let allow_insecure_algos = ssh_options.allow_insecure_algos.unwrap_or(false); for key in keys.into_iter() { let key = Arc::new(key); if key.key_data().is_rsa() && best_hash.is_none() && !allow_insecure_algos { info!("Skipping ssh-rsa (SHA1) key authentication since insecure SSH algos are not allowed for this target"); continue; } let key_str = key.public_key().to_openssh().map_err(russh::Error::from)?; let mut response = session .authenticate_publickey( ssh_options.username.clone(), PrivateKeyWithHashAlg::new(key.clone(), best_hash), ) .await?; auth_result = self._handle_auth_result( &mut session, ssh_options.username.clone(), response ).await.unwrap_or(false); if !auth_result && key.key_data().is_rsa() && best_hash.is_some() && allow_insecure_algos { // Corner case: OpenSSH advertising rsa2-sha-* through server-sig-algs, but it being // disabled via PubkeyAcceptedAlgorithms. So far the only case is our own test suite. // In this case we retry with ssh-rsa (SHA1) response = session .authenticate_publickey( ssh_options.username.clone(), PrivateKeyWithHashAlg::new(key.clone(), None), ).await?; auth_result = self._handle_auth_result( &mut session, ssh_options.username.clone(), response ).await.unwrap_or(false); } if auth_result { debug!(username=&ssh_options.username[..], key=%key_str, "Authenticated with key"); break; } else { auth_error_msg = Some("Public key authentication was rejected by the SSH target".into()); } } } } if !auth_result { let reason = auth_error_msg.unwrap_or_else(|| "Authentication was rejected by the SSH target".to_string()); error!(%reason, "Warpgate could not authenticate with SSH target"); let _ = session .disconnect(russh::Disconnect::ByApplication, "", "") .await; return Err(ConnectionError::Authentication); } self.session = Some(Arc::new(Mutex::new(session))); info!(?address, "Connected"); tokio::spawn({ let inner_event_tx = self.inner_event_tx.clone(); async move { while let Some(e) = event_rx.recv().await { info!("{:?}", e); inner_event_tx.send(InnerEvent::ClientHandlerEvent(e))? } Ok::<(), anyhow::Error>(()) } }.instrument(Span::current())); return Ok(()) } } } } /// Handles an AuthResult from a password or public key authentication attempt. /// If presented with an additional keyboard-interactive challenge it will respond with empty /// strings. This ensures optional 2fa is respected, where this extra challenge always happens. /// /// TODO: Optionally implement forwarding the challenges to the user /// /// # Arguments /// /// * `session`: the session for which the initial result is /// * `username`: username of the authenticating user /// * `result`: the initial result received via the configured auth method async fn _handle_auth_result( &self, session: &mut Handle, username: String, result: AuthResult, ) -> Result { debug!("Handling AuthResult"); match result { AuthResult::Success => { debug!("AuthResult is already success, no further handling needed"); return Ok(true); } AuthResult::Failure { remaining_methods: methods, .. } => { debug!("Initial auth failed, checking remaining methods"); for method in methods.iter() { if matches!(method, MethodKind::KeyboardInteractive) { debug!("Found keyboard-interactive challenge"); let mut kb_result = session .authenticate_keyboard_interactive_start(username.clone(), None) .await?; while let KeyboardInteractiveAuthResponse::InfoRequest { name: _name, instructions: _instructions, prompts, } = kb_result { for prompt in prompts.iter().clone() { debug!( prompt = prompt.prompt, echo = prompt.echo, "Prompt received for keyboard-interactive" ); } debug!("Responding with empty responses"); kb_result = session .authenticate_keyboard_interactive_respond(vec![ String::new(); prompts.len() ]) .await?; } match kb_result { KeyboardInteractiveAuthResponse::Success => { debug!("keyboard-interactive challenge successful"); return Ok(true); } KeyboardInteractiveAuthResponse::Failure { remaining_methods: _remaining_methods, .. } => { debug!("keyboard-interactive challenge failed"); return Ok(false); } _ => {} } } continue; } } } Ok(false) } async fn open_shell(&mut self, channel_id: Uuid) -> Result<(), SshClientError> { if let Some(session) = &self.session { let session = session.lock().await; let channel = session.channel_open_session().await?; let (tx, rx) = unbounded_channel(); self.channel_pipes.lock().await.insert(channel_id, tx); let channel = SessionChannel::new(channel, channel_id, rx, self.tx.clone(), self.id); self.child_tasks.push( tokio::task::Builder::new() .name(&format!("SSH {} {:?} ops", self.id, channel_id)) .spawn(channel.run()) .map_err(|e| SshClientError::Other(Box::new(e)))?, ); } Ok(()) } async fn open_direct_tcpip( &mut self, channel_id: Uuid, params: DirectTCPIPParams, ) -> Result<(), SshClientError> { if let Some(session) = &self.session { let session = session.lock().await; let channel = session .channel_open_direct_tcpip( params.host_to_connect, params.port_to_connect, params.originator_address, params.originator_port, ) .await?; let (tx, rx) = unbounded_channel(); self.channel_pipes.lock().await.insert(channel_id, tx); let channel = DirectTCPIPChannel::new(channel, channel_id, rx, self.tx.clone(), self.id); self.child_tasks.push( tokio::task::Builder::new() .name(&format!("SSH {} {:?} ops", self.id, channel_id)) .spawn(channel.run()) .map_err(|e| SshClientError::Other(Box::new(e)))?, ); } Ok(()) } async fn open_direct_streamlocal( &mut self, channel_id: Uuid, path: String, ) -> Result<(), SshClientError> { if let Some(session) = &self.session { let session = session.lock().await; let channel = session.channel_open_direct_streamlocal(path).await?; let (tx, rx) = unbounded_channel(); self.channel_pipes.lock().await.insert(channel_id, tx); let channel = DirectTCPIPChannel::new(channel, channel_id, rx, self.tx.clone(), self.id); self.child_tasks.push( tokio::task::Builder::new() .name(&format!("SSH {} {:?} ops", self.id, channel_id)) .spawn(channel.run()) .map_err(|e| SshClientError::Other(Box::new(e)))?, ); } Ok(()) } async fn tcpip_forward(&mut self, address: String, port: u32) -> Result<(), SshClientError> { if let Some(session) = &self.session { let session = session.lock().await; session.tcpip_forward(address, port).await?; } else { self.pending_forwards.push((address, port)); } Ok(()) } async fn cancel_tcpip_forward( &mut self, address: String, port: u32, ) -> Result<(), SshClientError> { if let Some(session) = &self.session { let session = session.lock().await; session.cancel_tcpip_forward(address, port).await?; } else { self.pending_forwards .retain(|x| x.0 != address || x.1 != port); } Ok(()) } async fn streamlocal_forward(&mut self, socket_path: String) -> Result<(), SshClientError> { if let Some(session) = &self.session { let session = session.lock().await; session.streamlocal_forward(socket_path).await?; } else { self.pending_streamlocal_forwards.push(socket_path); } Ok(()) } async fn cancel_streamlocal_forward( &mut self, socket_path: String, ) -> Result<(), SshClientError> { if let Some(session) = &self.session { let session = session.lock().await; session.cancel_streamlocal_forward(socket_path).await?; } else { self.pending_streamlocal_forwards .retain(|x| x != &socket_path); } Ok(()) } async fn disconnect(&mut self) { if let Some(session) = &mut self.session { let _ = session .lock() .await .disconnect(russh::Disconnect::ByApplication, "", "") .await; self.set_disconnected(); } } async fn _on_disconnect(&mut self) -> Result<()> { self.set_disconnected(); Ok(()) } } impl Drop for RemoteClient { fn drop(&mut self) { for task in self.child_tasks.drain(..) { task.abort(); } info!("Closed connection"); debug!("Dropped"); } } ================================================ FILE: warpgate-protocol-ssh/src/common.rs ================================================ use std::fmt::{Display, Formatter}; use bytes::Bytes; use russh::{ChannelId, Pty, Sig}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug)] pub struct PtyRequest { pub term: String, pub col_width: u32, pub row_height: u32, pub pix_width: u32, pub pix_height: u32, pub modes: Vec<(Pty, u32)>, } #[derive(Clone, Copy, Debug, PartialEq, Hash, Eq)] pub struct ServerChannelId(pub ChannelId); impl Display for ServerChannelId { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } #[derive(Clone, Debug)] pub struct DirectTCPIPParams { pub host_to_connect: String, pub port_to_connect: u32, pub originator_address: String, pub originator_port: u32, } #[derive(Clone, Debug)] pub struct ForwardedTcpIpParams { pub connected_address: String, pub connected_port: u32, pub originator_address: String, pub originator_port: u32, } #[derive(Clone, Debug)] pub struct ForwardedStreamlocalParams { pub socket_path: String, } #[derive(Clone, Debug)] pub struct X11Request { pub single_conection: bool, pub x11_auth_protocol: String, pub x11_auth_cookie: String, pub x11_screen_number: u32, } #[derive(Clone, Debug)] pub enum ChannelOperation { OpenShell, OpenDirectTCPIP(DirectTCPIPParams), OpenDirectStreamlocal(String), OpenX11(String, u32), RequestPty(PtyRequest), ResizePty(PtyRequest), RequestShell, RequestEnv(String, String), RequestExec(String), RequestX11(X11Request), AgentForward, RequestSubsystem(String), Data(Bytes), ExtendedData { data: Bytes, ext: u32 }, Close, Eof, Signal(Sig), } #[derive(Serialize, Deserialize, Debug)] #[serde(tag = "type")] pub enum SshRecordingMetadata { #[serde(rename = "ssh-shell")] Shell { channel: usize }, #[serde(rename = "ssh-exec")] Exec { channel: usize }, #[serde(rename = "ssh-direct-tcpip")] DirectTcpIp { host: String, port: u16 }, #[serde(rename = "ssh-direct-socket")] DirectSocket { path: String }, #[serde(rename = "ssh-forwarded-tcpip")] ForwardedTcpIp { host: String, port: u16 }, #[serde(rename = "ssh-forwarded-socket")] ForwardedSocket { path: String }, } ================================================ FILE: warpgate-protocol-ssh/src/compat.rs ================================================ use std::fmt::Display; pub trait ContextExt { fn context(self, context: C) -> anyhow::Result; } impl ContextExt for Result where C: Display + Send + Sync + 'static, { fn context(self, context: C) -> anyhow::Result { self.map_err(|_| anyhow::anyhow!("unspecified error").context(context)) } } ================================================ FILE: warpgate-protocol-ssh/src/keys.rs ================================================ use std::fs::{create_dir_all, File}; use std::path::PathBuf; use anyhow::{Context, Result}; use russh::keys::{encode_pkcs8_pem, load_secret_key, HashAlg, PrivateKey}; use tracing::*; use warpgate_common::helpers::fs::{secure_directory, secure_file}; use warpgate_common::helpers::rng::get_crypto_rng; use warpgate_common::{GlobalParams, WarpgateConfig}; fn get_keys_path(config: &WarpgateConfig, params: &GlobalParams) -> PathBuf { let mut path = params.paths_relative_to().clone(); path.push(&config.store.ssh.keys); path } pub fn generate_keys(config: &WarpgateConfig, params: &GlobalParams, prefix: &str) -> Result<()> { let path = get_keys_path(config, params); create_dir_all(&path)?; if params.should_secure_files() { secure_directory(&path)?; } for (algo, name) in [ (russh::keys::Algorithm::Ed25519, format!("{prefix}-ed25519")), ( russh::keys::Algorithm::Rsa { hash: Some(HashAlg::Sha512), }, format!("{prefix}-rsa"), ), ] { let key_path = path.join(name); if !key_path.exists() { info!("Generating {prefix} key ({algo:?})"); let key = PrivateKey::random(&mut get_crypto_rng(), algo) .context("Failed to generate key")?; let f = File::create(&key_path)?; encode_pkcs8_pem(&key, f)?; } if params.should_secure_files() { secure_file(&key_path)?; } } Ok(()) } pub fn load_keys( config: &WarpgateConfig, params: &GlobalParams, prefix: &str, ) -> Result, russh::keys::Error> { let path = get_keys_path(config, params); Ok(vec![ load_secret_key(path.join(format!("{prefix}-ed25519")), None)?, load_secret_key(path.join(format!("{prefix}-rsa")), None)?, ]) } ================================================ FILE: warpgate-protocol-ssh/src/known_hosts.rs ================================================ use std::sync::Arc; use russh::keys::{PublicKey, PublicKeyBase64}; use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter}; use tokio::sync::Mutex; use uuid::Uuid; use warpgate_db_entities::KnownHost; pub struct KnownHosts { db: Arc>, } pub enum KnownHostValidationResult { Valid, Invalid { key_type: String, key_base64: String, }, Unknown, } impl KnownHosts { pub fn new(db: &Arc>) -> Self { Self { db: db.clone() } } pub async fn validate( &mut self, host: &str, port: u16, key: &PublicKey, ) -> Result { let db = self.db.lock().await; let entries = KnownHost::Entity::find() .filter(KnownHost::Column::Host.eq(host)) .filter(KnownHost::Column::Port.eq(port)) .filter(KnownHost::Column::KeyType.eq(key.algorithm().as_str())) .all(&*db) .await?; let key_base64 = key.public_key_base64(); if entries.iter().any(|x| x.key_base64 == key_base64) { return Ok(KnownHostValidationResult::Valid); } if let Some(first) = entries.first() { return Ok(KnownHostValidationResult::Invalid { key_type: first.key_type.clone(), key_base64: first.key_base64.clone(), }); } Ok(KnownHostValidationResult::Unknown) } pub async fn trust( &mut self, host: &str, port: u16, key: &PublicKey, ) -> Result<(), sea_orm::DbErr> { use sea_orm::ActiveValue::Set; let values = KnownHost::ActiveModel { id: Set(Uuid::new_v4()), host: Set(host.to_owned()), port: Set(port.into()), key_type: Set(key.algorithm().to_string()), key_base64: Set(key.public_key_base64()), }; let db = self.db.lock().await; values.insert(&*db).await?; Ok(()) } } ================================================ FILE: warpgate-protocol-ssh/src/lib.rs ================================================ mod client; mod common; mod compat; mod keys; pub mod known_hosts; mod server; use std::fmt::Debug; use anyhow::Result; pub use client::*; pub use common::*; pub use keys::*; pub use server::run_server; use warpgate_common::{ListenEndpoint, ProtocolName}; use warpgate_core::{ProtocolServer, Services}; pub static PROTOCOL_NAME: ProtocolName = "SSH"; #[derive(Clone)] pub struct SSHProtocolServer { services: Services, } impl SSHProtocolServer { pub async fn new(services: &Services) -> Result { let config = services.config.lock().await; generate_keys(&config, &services.global_params, "host")?; generate_keys(&config, &services.global_params, "client")?; Ok(SSHProtocolServer { services: services.clone(), }) } } impl ProtocolServer for SSHProtocolServer { async fn run(self, address: ListenEndpoint) -> Result<()> { run_server(self.services, address).await } fn name(&self) -> &'static str { "SSH" } } impl Debug for SSHProtocolServer { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("SSHProtocolServer").finish() } } ================================================ FILE: warpgate-protocol-ssh/src/server/channel_writer.rs ================================================ use russh::server::Handle; use russh::ChannelId; use tokio::sync::mpsc; #[derive(Debug)] enum ChannelWriteOperation { Data(Handle, ChannelId, Vec), ExtendedData(Handle, ChannelId, u32, Vec), Flush(tokio::sync::oneshot::Sender<()>), } /// Sequences data writes and runs them in background to avoid lockups pub struct ChannelWriter { tx: mpsc::UnboundedSender, } impl ChannelWriter { pub fn new() -> Self { let (tx, mut rx) = mpsc::unbounded_channel::(); tokio::spawn(async move { while let Some(operation) = rx.recv().await { match operation { ChannelWriteOperation::Data(handle, channel, data) => { let _ = handle.data(channel, data).await; } ChannelWriteOperation::ExtendedData(handle, channel, ext, data) => { let _ = handle.extended_data(channel, ext, data).await; } ChannelWriteOperation::Flush(reply) => { let _ = reply.send(()); } } } }); ChannelWriter { tx } } pub fn write>>(&self, handle: Handle, channel: ChannelId, data: D) { let _ = self .tx .send(ChannelWriteOperation::Data(handle, channel, data.into())); } pub fn write_extended>>( &self, handle: Handle, channel: ChannelId, ext: u32, data: D, ) { let _ = self.tx.send(ChannelWriteOperation::ExtendedData( handle, channel, ext, data.into(), )); } /// Flush all pending writes. Returns when all previously queued operations have completed. pub async fn flush(&self) -> Result<(), Box> { let (tx, rx) = tokio::sync::oneshot::channel(); self.tx .send(ChannelWriteOperation::Flush(tx)) .map_err(|_| "ChannelWriter task has stopped")?; rx.await.map_err(|_| "ChannelWriter flush failed")?; Ok(()) } } ================================================ FILE: warpgate-protocol-ssh/src/server/mod.rs ================================================ mod channel_writer; mod russh_handler; mod service_output; mod session; mod session_handle; use std::borrow::Cow; use std::sync::Arc; use std::time::Duration; use anyhow::{Context, Result}; use futures::TryStreamExt; use russh::keys::{Algorithm, HashAlg, PrivateKey}; use russh::{MethodKind, MethodSet, Preferred}; pub use russh_handler::ServerHandler; pub use session::ServerSession; use tokio::io::{AsyncRead, AsyncWrite}; use tokio::net::TcpStream; use tokio::sync::mpsc::unbounded_channel; use tracing::*; use warpgate_common::ListenEndpoint; use warpgate_core::{Services, SessionStateInit, State}; use warpgate_db_entities::Parameters; use crate::keys::load_keys; use crate::server::session_handle::SSHSessionHandle; #[derive(Clone)] struct RusshConfigInit { keys: Vec, } pub async fn run_server(services: Services, address: ListenEndpoint) -> Result<()> { let russh_config_init = Arc::new({ let config = services.config.lock().await; RusshConfigInit { keys: load_keys(&config, &services.global_params, "host")?, } }); let mut listener = address.tcp_accept_stream().await?; while let Some(stream) = listener.try_next().await.context("accepting connection")? { let russh_config_init = russh_config_init.clone(); let services = services.clone(); tokio::task::Builder::new() .name("SSH new connection setup") .spawn(async move { if let Err(e) = _handle_connection(services, russh_config_init, stream).await { error!(%e, "Connection handling failed"); } })?; } Ok(()) } async fn _handle_connection( services: Services, russh_config_init: Arc, stream: TcpStream, ) -> Result<()> { stream.set_nodelay(true)?; let remote_address = stream.peer_addr().context("getting peer address")?; let (session_handle, session_handle_rx) = SSHSessionHandle::new(); let server_handle = State::register_session( &services.state, &crate::PROTOCOL_NAME, SessionStateInit { remote_address: Some(remote_address), handle: Box::new(session_handle), }, ) .await .context("registering session")?; let id = server_handle.lock().await.id(); let (event_tx, event_rx) = unbounded_channel(); let handler = ServerHandler { event_tx }; let wrapped_stream = server_handle.lock().await.wrap_stream(stream).await?; let session = match ServerSession::start( remote_address, &services, server_handle, session_handle_rx, event_rx, ) .await { Ok(session) => session, Err(error) => { error!(%error, "Error setting up session"); return Err(error); } }; let russh_config = { let config = services.config.lock().await; russh::server::Config { auth_rejection_time: Duration::from_secs(1), auth_rejection_time_initial: Some(Duration::from_secs(0)), inactivity_timeout: Some(config.store.ssh.inactivity_timeout), keepalive_interval: config.store.ssh.keepalive_interval, methods: get_allowed_auth_methods(&services).await?, keys: russh_config_init.keys.clone(), event_buffer_size: 100, nodelay: true, preferred: Preferred { key: Cow::Borrowed(&[ Algorithm::Ed25519, Algorithm::Rsa { hash: Some(HashAlg::Sha512), }, Algorithm::Rsa { hash: Some(HashAlg::Sha256), }, Algorithm::Rsa { hash: None }, ]), ..<_>::default() }, ..<_>::default() } }; let russh_config = Arc::new(russh_config); tokio::task::Builder::new() .name(&format!("SSH {id} session")) .spawn(session)?; tokio::task::Builder::new() .name(&format!("SSH {id} protocol")) .spawn(_run_stream(russh_config, wrapped_stream, handler))?; Ok(()) } async fn _run_stream( config: Arc, socket: R, handler: ServerHandler, ) -> Result<()> where R: AsyncRead + AsyncWrite + Unpin + Send + 'static, { let ret = async move { let session = russh::server::run_stream(config, socket, handler).await?; session.await?; Ok(()) } .await; if let Err(ref error) = ret { error!(%error, "Session failed"); } ret } pub(crate) async fn get_allowed_auth_methods(services: &Services) -> Result { let parameters = { let db = services.db.lock().await; Parameters::Entity::get(&db).await? }; let mut methods_vec: Vec = Vec::new(); if parameters.ssh_client_auth_publickey { methods_vec.push(MethodKind::PublicKey); } if parameters.ssh_client_auth_password { methods_vec.push(MethodKind::Password); } if parameters.ssh_client_auth_keyboard_interactive { methods_vec.push(MethodKind::KeyboardInteractive); } if methods_vec.is_empty() { warn!("All SSH authentication methods are disabled in parameters. Enabling all methods as fallback."); } Ok(MethodSet::from(&methods_vec[..])) } ================================================ FILE: warpgate-protocol-ssh/src/server/russh_handler.rs ================================================ use std::fmt::Debug; use bytes::Bytes; use russh::keys::PublicKey; use russh::server::{Auth, Handle, Msg, Session}; use russh::{Channel, ChannelId, Pty, Sig}; use tokio::sync::mpsc::UnboundedSender; use tokio::sync::oneshot; use tracing::*; use warpgate_common::Secret; use crate::common::{PtyRequest, ServerChannelId}; use crate::{DirectTCPIPParams, X11Request}; pub struct HandleWrapper(pub Handle); impl Debug for HandleWrapper { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "HandleWrapper") } } #[derive(Debug)] pub enum ServerHandlerEvent { Authenticated(HandleWrapper), ChannelOpenSession(ServerChannelId, oneshot::Sender), SubsystemRequest(ServerChannelId, String, oneshot::Sender), PtyRequest(ServerChannelId, PtyRequest, oneshot::Sender<()>), ShellRequest(ServerChannelId, oneshot::Sender), AuthPublicKey(Secret, PublicKey, oneshot::Sender), AuthPublicKeyOffer(Secret, PublicKey, oneshot::Sender), AuthPassword(Secret, Secret, oneshot::Sender), AuthKeyboardInteractive( Secret, Option>, oneshot::Sender, ), Data(ServerChannelId, Bytes, oneshot::Sender<()>), ExtendedData(ServerChannelId, Bytes, u32, oneshot::Sender<()>), ChannelClose(ServerChannelId, oneshot::Sender<()>), ChannelEof(ServerChannelId, oneshot::Sender<()>), WindowChangeRequest(ServerChannelId, PtyRequest, oneshot::Sender<()>), Signal(ServerChannelId, Sig, oneshot::Sender<()>), ExecRequest(ServerChannelId, Bytes, oneshot::Sender), ChannelOpenDirectTcpIp(ServerChannelId, DirectTCPIPParams, oneshot::Sender), ChannelOpenDirectStreamlocal(ServerChannelId, String, oneshot::Sender), EnvRequest(ServerChannelId, String, String, oneshot::Sender<()>), X11Request(ServerChannelId, X11Request, oneshot::Sender<()>), TcpIpForward(String, u32, oneshot::Sender), CancelTcpIpForward(String, u32, oneshot::Sender), StreamlocalForward(String, oneshot::Sender), CancelStreamlocalForward(String, oneshot::Sender), AgentForward(ServerChannelId, oneshot::Sender), Disconnect, } pub struct ServerHandler { pub event_tx: UnboundedSender, } #[derive(thiserror::Error, Debug)] pub enum ServerHandlerError { #[error("channel disconnected")] ChannelSend, } impl ServerHandler { fn send_event(&self, event: ServerHandlerEvent) -> Result<(), ServerHandlerError> { self.event_tx .send(event) .map_err(|_| ServerHandlerError::ChannelSend) } } impl russh::server::Handler for ServerHandler { type Error = anyhow::Error; async fn auth_succeeded(&mut self, session: &mut Session) -> Result<(), Self::Error> { let handle = session.handle(); self.send_event(ServerHandlerEvent::Authenticated(HandleWrapper(handle)))?; Ok(()) } async fn channel_open_session( &mut self, channel: Channel, _session: &mut Session, ) -> Result { let (tx, rx) = oneshot::channel(); self.send_event(ServerHandlerEvent::ChannelOpenSession( ServerChannelId(channel.id()), tx, ))?; let allowed = rx.await.unwrap_or(false); Ok(allowed) } async fn subsystem_request( &mut self, channel: ChannelId, name: &str, session: &mut Session, ) -> Result<(), Self::Error> { let name = name.to_string(); let (tx, rx) = oneshot::channel(); self.send_event(ServerHandlerEvent::SubsystemRequest( ServerChannelId(channel), name, tx, ))?; if rx.await.unwrap_or(false) { session.channel_success(channel)? } else { session.channel_failure(channel)? } Ok(()) } async fn pty_request( &mut self, channel: ChannelId, term: &str, col_width: u32, row_height: u32, pix_width: u32, pix_height: u32, modes: &[(Pty, u32)], session: &mut Session, ) -> Result<(), Self::Error> { let term = term.to_string(); let modes = modes .iter() .take_while(|x| (x.0 as u8) > 0 && (x.0 as u8) < 160) .map(Clone::clone) .collect(); let (tx, rx) = oneshot::channel(); self.send_event(ServerHandlerEvent::PtyRequest( ServerChannelId(channel), PtyRequest { term, col_width, row_height, pix_width, pix_height, modes, }, tx, ))?; let _ = rx.await; session.channel_success(channel)?; Ok(()) } async fn shell_request( &mut self, channel: ChannelId, session: &mut Session, ) -> Result<(), Self::Error> { let (tx, rx) = oneshot::channel(); self.send_event(ServerHandlerEvent::ShellRequest( ServerChannelId(channel), tx, ))?; if rx.await.unwrap_or(false) { session.channel_success(channel)? } else { session.channel_failure(channel)? } Ok(()) } async fn auth_publickey_offered( &mut self, user: &str, key: &russh::keys::PublicKey, ) -> Result { let user = Secret::new(user.to_string()); let (tx, rx) = oneshot::channel(); self.send_event(ServerHandlerEvent::AuthPublicKeyOffer( user, key.clone(), tx, ))?; Ok(rx.await.unwrap_or(Auth::reject())) } async fn auth_publickey( &mut self, user: &str, key: &russh::keys::PublicKey, ) -> Result { let user = Secret::new(user.to_string()); let (tx, rx) = oneshot::channel(); self.send_event(ServerHandlerEvent::AuthPublicKey(user, key.clone(), tx))?; let result = rx.await.unwrap_or(Auth::UnsupportedMethod); Ok(result) } async fn auth_password(&mut self, user: &str, password: &str) -> Result { let user = Secret::new(user.to_string()); let password = Secret::new(password.to_string()); let (tx, rx) = oneshot::channel(); self.send_event(ServerHandlerEvent::AuthPassword(user, password, tx))?; let result = rx.await.unwrap_or(Auth::UnsupportedMethod); Ok(result) } async fn auth_keyboard_interactive<'a>( &'a mut self, user: &str, _submethods: &str, response: Option>, ) -> Result { let user = Secret::new(user.to_string()); let response = response .and_then(|mut r| r.next()) .and_then(|b| String::from_utf8(b.to_vec()).ok()) .map(Secret::new); let (tx, rx) = oneshot::channel(); self.send_event(ServerHandlerEvent::AuthKeyboardInteractive( user, response, tx, ))?; let result = rx.await.unwrap_or(Auth::UnsupportedMethod); Ok(result) } async fn data( &mut self, channel: ChannelId, data: &[u8], _session: &mut Session, ) -> Result<(), Self::Error> { let channel = ServerChannelId(channel); let data = Bytes::from(data.to_vec()); let (tx, rx) = oneshot::channel(); self.send_event(ServerHandlerEvent::Data(channel, data, tx))?; let _ = rx.await; Ok(()) } async fn extended_data( &mut self, channel: ChannelId, code: u32, data: &[u8], _session: &mut Session, ) -> Result<(), Self::Error> { let channel = ServerChannelId(channel); let data = Bytes::from(data.to_vec()); let (tx, rx) = oneshot::channel(); self.send_event(ServerHandlerEvent::ExtendedData(channel, data, code, tx))?; let _ = rx.await; Ok(()) } async fn channel_close( &mut self, channel: ChannelId, _session: &mut Session, ) -> Result<(), Self::Error> { let channel = ServerChannelId(channel); let (tx, rx) = oneshot::channel(); self.send_event(ServerHandlerEvent::ChannelClose(channel, tx))?; let _ = rx.await; Ok(()) } async fn window_change_request( &mut self, channel: ChannelId, col_width: u32, row_height: u32, pix_width: u32, pix_height: u32, _session: &mut Session, ) -> Result<(), Self::Error> { let (tx, rx) = oneshot::channel(); self.send_event(ServerHandlerEvent::WindowChangeRequest( ServerChannelId(channel), PtyRequest { term: "".to_string(), col_width, row_height, pix_width, pix_height, modes: vec![], }, tx, ))?; let _ = rx.await; Ok(()) } async fn channel_eof( &mut self, channel: ChannelId, _session: &mut Session, ) -> Result<(), Self::Error> { let channel = ServerChannelId(channel); let (tx, rx) = oneshot::channel(); self.event_tx .send(ServerHandlerEvent::ChannelEof(channel, tx)) .map_err(|_| ServerHandlerError::ChannelSend)?; let _ = rx.await; Ok(()) } async fn signal( &mut self, channel: ChannelId, signal_name: russh::Sig, _session: &mut Session, ) -> Result<(), Self::Error> { let (tx, rx) = oneshot::channel(); self.send_event(ServerHandlerEvent::Signal( ServerChannelId(channel), signal_name, tx, ))?; let _ = rx.await; Ok(()) } async fn exec_request( &mut self, channel: ChannelId, data: &[u8], session: &mut Session, ) -> Result<(), Self::Error> { let data = Bytes::from(data.to_vec()); let (tx, rx) = oneshot::channel(); self.send_event(ServerHandlerEvent::ExecRequest( ServerChannelId(channel), data, tx, ))?; if rx.await.unwrap_or(false) { session.channel_success(channel)? } else { session.channel_failure(channel)? } Ok(()) } async fn env_request( &mut self, channel: ChannelId, variable_name: &str, variable_value: &str, _session: &mut Session, ) -> Result<(), Self::Error> { let variable_name = variable_name.to_string(); let variable_value = variable_value.to_string(); let (tx, rx) = oneshot::channel(); self.send_event(ServerHandlerEvent::EnvRequest( ServerChannelId(channel), variable_name, variable_value, tx, ))?; let _ = rx.await; Ok(()) } async fn channel_open_direct_tcpip( &mut self, channel: Channel, host_to_connect: &str, port_to_connect: u32, originator_address: &str, originator_port: u32, _session: &mut Session, ) -> Result { let host_to_connect = host_to_connect.to_string(); let originator_address = originator_address.to_string(); let (tx, rx) = oneshot::channel(); self.send_event(ServerHandlerEvent::ChannelOpenDirectTcpIp( ServerChannelId(channel.id()), DirectTCPIPParams { host_to_connect, port_to_connect, originator_address, originator_port, }, tx, ))?; let allowed = rx.await.unwrap_or(false); Ok(allowed) } async fn channel_open_direct_streamlocal( &mut self, channel: Channel, socket_path: &str, _session: &mut Session, ) -> Result { let socket_path = socket_path.to_string(); let (tx, rx) = oneshot::channel(); self.send_event(ServerHandlerEvent::ChannelOpenDirectStreamlocal( ServerChannelId(channel.id()), socket_path, tx, ))?; let allowed = rx.await.unwrap_or(false); Ok(allowed) } async fn x11_request( &mut self, channel: ChannelId, single_conection: bool, x11_auth_protocol: &str, x11_auth_cookie: &str, x11_screen_number: u32, _session: &mut Session, ) -> Result<(), Self::Error> { let x11_auth_protocol = x11_auth_protocol.to_string(); let x11_auth_cookie = x11_auth_cookie.to_string(); let (tx, rx) = oneshot::channel(); self.send_event(ServerHandlerEvent::X11Request( ServerChannelId(channel), X11Request { single_conection, x11_auth_protocol, x11_auth_cookie, x11_screen_number, }, tx, ))?; let _ = rx.await; Ok(()) } async fn tcpip_forward( &mut self, address: &str, port: &mut u32, session: &mut Session, ) -> Result { let address = address.to_string(); let port = *port; let (tx, rx) = oneshot::channel(); self.send_event(ServerHandlerEvent::TcpIpForward(address, port, tx))?; let allowed = rx.await.unwrap_or(false); if allowed { session.request_success() } else { session.request_failure() } Ok(allowed) } async fn cancel_tcpip_forward( &mut self, address: &str, port: u32, session: &mut Session, ) -> Result { let address = address.to_string(); let (tx, rx) = oneshot::channel(); self.send_event(ServerHandlerEvent::CancelTcpIpForward(address, port, tx))?; let allowed = rx.await.unwrap_or(false); if allowed { session.request_success() } else { session.request_failure() } Ok(allowed) } async fn streamlocal_forward( &mut self, socket_path: &str, session: &mut Session, ) -> Result { let socket_path = socket_path.to_string(); let (tx, rx) = oneshot::channel(); self.send_event(ServerHandlerEvent::StreamlocalForward(socket_path, tx))?; let allowed = rx.await.unwrap_or(false); if allowed { session.request_success() } else { session.request_failure() } Ok(allowed) } async fn cancel_streamlocal_forward( &mut self, socket_path: &str, session: &mut Session, ) -> Result { let socket_path = socket_path.to_string(); let (tx, rx) = oneshot::channel(); self.send_event(ServerHandlerEvent::CancelStreamlocalForward( socket_path, tx, ))?; let allowed = rx.await.unwrap_or(false); if allowed { session.request_success() } else { session.request_failure() } Ok(allowed) } async fn agent_request( &mut self, channel: ChannelId, session: &mut Session, ) -> Result { let (tx, rx) = oneshot::channel(); self.send_event(ServerHandlerEvent::AgentForward( ServerChannelId(channel), tx, ))?; let allowed = rx.await.unwrap_or(false); if allowed { session.request_success() } else { session.request_failure() } Ok(allowed) } } impl Drop for ServerHandler { fn drop(&mut self) { debug!("Dropped"); let _ = self.event_tx.send(ServerHandlerEvent::Disconnect); } } impl Debug for ServerHandler { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "ServerHandler") } } ================================================ FILE: warpgate-protocol-ssh/src/server/service_output.rs ================================================ use std::sync::atomic::AtomicBool; use std::sync::Arc; use ansi_term::Colour; use bytes::Bytes; use tokio::sync::{broadcast, mpsc}; pub const ERASE_PROGRESS_SPINNER: &str = "\r \r"; pub const ERASE_PROGRESS_SPINNER_BUF: &[u8] = ERASE_PROGRESS_SPINNER.as_bytes(); pub const LINEBREAK: &[u8] = "\n".as_bytes(); #[derive(Clone)] pub struct ServiceOutput { progress_visible: Arc, abort_tx: mpsc::Sender<()>, output_tx: broadcast::Sender, } impl ServiceOutput { pub fn new() -> Self { let progress_visible = Arc::new(AtomicBool::new(false)); let (abort_tx, mut abort_rx) = mpsc::channel(1); let output_tx = broadcast::channel(32).0; tokio::spawn({ let output_tx = output_tx.clone(); let progress_visible = progress_visible.clone(); let ticks = "⠁⠁⠉⠙⠚⠒⠂⠂⠒⠲⠴⠤⠄⠄⠤⠠⠠⠤⠦⠖⠒⠐⠐⠒⠓⠋⠉⠈⠈".chars().collect::>(); let mut tick_index = 0; async move { loop { tokio::select! { _ = abort_rx.recv() => { return; } _ = tokio::time::sleep(std::time::Duration::from_millis(100)) => { if progress_visible.load(std::sync::atomic::Ordering::Relaxed) { tick_index = (tick_index + 1) % ticks.len(); #[allow(clippy::indexing_slicing)] let tick = ticks[tick_index]; let badge = Colour::Black.on(Colour::Blue).paint(format!(" {tick} Warpgate connecting ")).to_string(); let _ = output_tx.send(Bytes::from([ERASE_PROGRESS_SPINNER_BUF, badge.as_bytes()].concat())); } } } } } }); ServiceOutput { progress_visible, abort_tx, output_tx, } } pub fn show_progress(&mut self) { self.progress_visible .store(true, std::sync::atomic::Ordering::Relaxed); } pub async fn hide_progress(&mut self) { self.progress_visible .store(false, std::sync::atomic::Ordering::Relaxed); self.emit_output(Bytes::from_static(ERASE_PROGRESS_SPINNER_BUF)); self.emit_output(Bytes::from_static(LINEBREAK)); } pub fn subscribe(&self) -> broadcast::Receiver { self.output_tx.subscribe() } pub fn emit_output(&mut self, output: Bytes) { let _ = self.output_tx.send(output); } } impl Drop for ServiceOutput { fn drop(&mut self) { let signal = std::mem::replace(&mut self.abort_tx, mpsc::channel(1).0); tokio::spawn(async move { signal.send(()).await }); } } ================================================ FILE: warpgate-protocol-ssh/src/server/session.rs ================================================ use std::borrow::Cow; use std::collections::hash_map::Entry::Vacant; use std::collections::{HashMap, HashSet}; use std::net::{Ipv4Addr, SocketAddr}; use std::pin::Pin; use std::str::FromStr; use std::sync::Arc; use std::task::Poll; use ansi_term::Colour; use anyhow::{Context, Result}; use bimap::BiMap; use bytes::Bytes; use futures::{Future, FutureExt}; use russh::keys::{PublicKey, PublicKeyBase64}; use russh::{MethodKind, MethodSet, Sig}; use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; use tokio::sync::{broadcast, oneshot, Mutex}; use tracing::*; use uuid::Uuid; use warpgate_common::auth::{ AuthCredential, AuthResult, AuthSelector, AuthState, AuthStateUserInfo, CredentialKind, }; use warpgate_common::eventhub::{EventHub, EventSender, EventSubscription}; use warpgate_common::{ Secret, SessionId, SshHostKeyVerificationMode, Target, TargetOptions, TargetSSHOptions, WarpgateError, }; use warpgate_core::recordings::{ self, ConnectionRecorder, TerminalRecorder, TerminalRecordingStreamId, TrafficConnectionParams, TrafficRecorder, }; use warpgate_core::{ authorize_ticket, consume_ticket, ConfigProvider, Services, WarpgateServerHandle, }; use super::channel_writer::ChannelWriter; use super::russh_handler::ServerHandlerEvent; use super::service_output::ServiceOutput; use super::session_handle::SessionHandleCommand; use crate::compat::ContextExt; use crate::server::get_allowed_auth_methods; use crate::server::service_output::ERASE_PROGRESS_SPINNER; use crate::{ ChannelOperation, ConnectionError, DirectTCPIPParams, PtyRequest, RCCommand, RCCommandReply, RCEvent, RCState, RemoteClient, ServerChannelId, SshClientError, SshRecordingMetadata, X11Request, }; #[derive(Clone)] #[allow(clippy::large_enum_variant)] enum TargetSelection { None, NotFound(String), Found(Target, TargetSSHOptions), } #[derive(Debug)] enum Event { Command(SessionHandleCommand), ServerHandler(ServerHandlerEvent), ConsoleInput(Bytes), ServiceOutput(Bytes), Client(RCEvent), } enum KeyboardInteractiveState { None, OtpRequested, WebAuthRequested(broadcast::Receiver), } struct CachedSuccessfulTicketAuth { ticket: Secret, user_info: AuthStateUserInfo, } #[derive(Debug, Hash, PartialEq, Eq, Clone)] pub enum TrafficRecorderKey { Tcp(String, u32), Socket(String), } pub struct ServerSession { pub id: SessionId, username: Option, session_handle: Option, pty_channels: Vec, all_channels: Vec, channel_recorders: HashMap, channel_map: BiMap, channel_pty_size_map: HashMap, rc_tx: UnboundedSender<(RCCommand, Option)>, rc_abort_tx: UnboundedSender<()>, rc_state: RCState, remote_address: SocketAddr, services: Services, server_handle: Arc>, target: TargetSelection, traffic_recorders: HashMap, traffic_connection_recorders: HashMap, hub: EventHub, event_sender: EventSender, main_event_subscription: EventSubscription, service_output: ServiceOutput, channel_writer: ChannelWriter, auth_state: Option>>, keyboard_interactive_state: KeyboardInteractiveState, cached_successful_ticket_auth: Option, allowed_auth_methods: MethodSet, } fn session_debug_tag(id: &SessionId, remote_address: &SocketAddr) -> String { format!("[{id} - {remote_address}]") } impl std::fmt::Debug for ServerSession { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", session_debug_tag(&self.id, &self.remote_address)) } } impl ServerSession { pub async fn start( remote_address: SocketAddr, services: &Services, server_handle: Arc>, mut session_handle_rx: UnboundedReceiver, mut handler_event_rx: UnboundedReceiver, ) -> Result>> { let id = server_handle.lock().await.id(); let _span = info_span!("SSH", session=%id); let _enter = _span.enter(); let mut rc_handles = RemoteClient::create(id, services.clone())?; let (hub, event_sender) = EventHub::setup(); let main_event_subscription = hub .subscribe(|e| !matches!(e, Event::ConsoleInput(_))) .await; let mut this = Self { id, username: None, session_handle: None, pty_channels: vec![], all_channels: vec![], channel_recorders: HashMap::new(), channel_map: BiMap::new(), channel_pty_size_map: HashMap::new(), rc_tx: rc_handles.command_tx.clone(), rc_abort_tx: rc_handles.abort_tx, rc_state: RCState::NotInitialized, remote_address, services: services.clone(), server_handle, target: TargetSelection::None, traffic_recorders: HashMap::new(), traffic_connection_recorders: HashMap::new(), hub, event_sender: event_sender.clone(), main_event_subscription, service_output: ServiceOutput::new(), channel_writer: ChannelWriter::new(), auth_state: None, keyboard_interactive_state: KeyboardInteractiveState::None, cached_successful_ticket_auth: None, allowed_auth_methods: get_allowed_auth_methods(services).await?, }; let mut so_rx = this.service_output.subscribe(); let so_sender = event_sender.clone(); tokio::spawn(async move { loop { match so_rx.recv().await { Ok(data) => { if so_sender .send_once(Event::ServiceOutput(data)) .await .is_err() { break; } } Err(broadcast::error::RecvError::Closed) => break, Err(_) => (), } } }); let name = format!("SSH {id} session control"); tokio::task::Builder::new().name(&name).spawn({ let sender = event_sender.clone(); async move { while let Some(command) = session_handle_rx.recv().await { if sender.send_once(Event::Command(command)).await.is_err() { break; } } } })?; let name = format!("SSH {id} client events"); tokio::task::Builder::new().name(&name).spawn({ let sender = event_sender.clone(); async move { while let Some(e) = rc_handles.event_rx.recv().await { if sender.send_once(Event::Client(e)).await.is_err() { break; } } } })?; let name = format!("SSH {id} server handler events"); tokio::task::Builder::new().name(&name).spawn({ let sender: EventSender = event_sender.clone(); async move { while let Some(e) = handler_event_rx.recv().await { if sender.send_once(Event::ServerHandler(e)).await.is_err() { break; } } } })?; Ok(async move { while let Some(event) = this.get_next_event().await { this.handle_event(event).await?; } debug!("No more events"); Ok::<_, anyhow::Error>(()) }) } async fn get_next_event(&mut self) -> Option { self.main_event_subscription.recv().await } async fn get_auth_state(&mut self, username: &str) -> Result>> { #[allow(clippy::unwrap_used)] if self.auth_state.is_none() || self .auth_state .as_ref() .unwrap() .lock() .await .user_info() .username != username { let state = self .services .auth_state_store .lock() .await .create( Some(&self.id), username, crate::PROTOCOL_NAME, &[ CredentialKind::Password, CredentialKind::PublicKey, CredentialKind::Totp, CredentialKind::WebUserApproval, ], ) .await? .1; self.auth_state = Some(state); } #[allow(clippy::unwrap_used)] Ok(self.auth_state.as_ref().cloned().unwrap()) } pub fn make_logging_span(&self) -> tracing::Span { let client_ip = self.remote_address.ip().to_string(); match self.username { Some(ref username) => { info_span!("SSH", session=%self.id, session_username=%username, %client_ip) } None => info_span!("SSH", session=%self.id, %client_ip), } } fn map_channel(&self, ch: &ServerChannelId) -> Result { self.channel_map .get_by_left(ch) .cloned() .ok_or(WarpgateError::InconsistentState) } fn map_channel_reverse(&self, ch: &Uuid) -> Result { self.channel_map .get_by_right(ch) .cloned() .ok_or_else(|| anyhow::anyhow!("Channel not known")) } pub async fn emit_service_message(&mut self, msg: &str) -> Result<()> { debug!("Service message: {}", msg); self.service_output.emit_output(Bytes::from(format!( "{}{} {}\r\n", ERASE_PROGRESS_SPINNER, Colour::Black.on(Colour::White).paint(" Warpgate "), msg.replace('\n', "\r\n"), ))); Ok(()) } pub async fn emit_pty_output(&mut self, data: &[u8]) -> Result<()> { let channels = self.pty_channels.clone(); for channel in channels { let channel = self.map_channel_reverse(&channel)?; if let Some(session) = self.session_handle.clone() { self.channel_writer.write(session, channel.0, data); } } Ok(()) } /// Start connecting to the target if we aren't already. /// /// Timing of this call is important because if the client connection is /// an interactive session *in principle* (e.g a normal interactive OpenSSH /// session but maybe with some port forwards or agent) /// Ideally, it needs to be called by the time we already have the interactive /// channel open if we will ever have one to prevent bugs like /// https://github.com/warp-tech/warpgate/issues/1286 /// where a PTY channel is required for the host key prompt, but we've connected /// faster than the client could open one. pub async fn maybe_connect_remote(&mut self) -> Result<()> { match self.target.clone() { TargetSelection::None => { anyhow::bail!("Invalid session state (target not set)") } TargetSelection::NotFound(name) => { self.emit_service_message(&format!("Selected target not found: {name}")) .await?; self.disconnect_server().await; anyhow::bail!("Target not found: {}", name); } TargetSelection::Found(target, ssh_options) => { if self.rc_state == RCState::NotInitialized { self.connect_remote(target, ssh_options).await?; } } } Ok(()) } async fn connect_remote( &mut self, target: Target, ssh_options: TargetSSHOptions, ) -> Result<()> { self.rc_state = RCState::Connecting; self.send_command(RCCommand::Connect(ssh_options)) .map_err(|_| anyhow::anyhow!("cannot send command"))?; self.service_output.show_progress(); self.emit_service_message(&format!("Selected target: {}", target.name)) .await?; Ok(()) } fn handle_event<'a>( &'a mut self, event: Event, ) -> Pin> + Send + 'a>> { async move { match event { Event::Client(RCEvent::Done) => Err(WarpgateError::SessionEnd)?, Event::ServerHandler(ServerHandlerEvent::Disconnect) => { Err(WarpgateError::SessionEnd)? } Event::Client(e) => { debug!(event=?e, "Event"); let span = self.make_logging_span(); if let Err(err) = self.handle_remote_event(e).instrument(span).await { error!("Client event handler error: {:?}", err); // break; } } Event::ServerHandler(e) => { let span = self.make_logging_span(); if let Err(err) = self.handle_server_handler_event(e).instrument(span).await { error!("Server event handler error: {:?}", err); // break; } } Event::Command(command) => { debug!(?command, "Session control"); if let Err(err) = self.handle_session_control(command).await { error!("Command handler error: {:?}", err); // break; } } Event::ServiceOutput(data) => { let _ = self.emit_pty_output(&data).await; } Event::ConsoleInput(_) => (), } Ok(()) } .boxed() } async fn handle_server_handler_event(&mut self, event: ServerHandlerEvent) -> Result<()> { match event { ServerHandlerEvent::Authenticated(handle) => { self.session_handle = Some(handle.0); } ServerHandlerEvent::ChannelOpenSession(server_channel_id, reply) => { let channel = Uuid::new_v4(); self.channel_map.insert(server_channel_id, channel); info!(%channel, "Opening session channel"); return match self .send_command_and_wait(RCCommand::Channel(channel, ChannelOperation::OpenShell)) .await { Ok(()) => { self.all_channels.push(channel); let _ = reply.send(true); Ok(()) } Err(SshClientError::Russh(russh::Error::ChannelOpenFailure(_))) => { let _ = reply.send(false); Ok(()) } Err(x) => Err(x.into()), }; } ServerHandlerEvent::SubsystemRequest(server_channel_id, name, reply) => { return match self ._channel_subsystem_request(server_channel_id, name) .await { Ok(()) => { let _ = reply.send(true); Ok(()) } Err(SshClientError::Russh(russh::Error::ChannelOpenFailure(_))) => { let _ = reply.send(false); Ok(()) } Err(x) => Err(x.into()), } } ServerHandlerEvent::PtyRequest(server_channel_id, request, reply) => { let channel_id = self.map_channel(&server_channel_id)?; self.channel_pty_size_map .insert(channel_id, request.clone()); if let Some(recorder) = self.channel_recorders.get_mut(&channel_id) { if let Err(error) = recorder .write_pty_resize(request.col_width, request.row_height) .await { error!(%channel_id, ?error, "Failed to record terminal data"); self.channel_recorders.remove(&channel_id); } } self.send_command_and_wait(RCCommand::Channel( channel_id, ChannelOperation::RequestPty(request), )) .await?; let _ = self .session_handle .as_mut() .context("Invalid session state")? .channel_success(server_channel_id.0) .await; self.pty_channels.push(channel_id); let _ = reply.send(()); } ServerHandlerEvent::ShellRequest(server_channel_id, reply) => { let channel_id = self.map_channel(&server_channel_id)?; let _ = self.maybe_connect_remote().await; let _ = self.send_command(RCCommand::Channel( channel_id, ChannelOperation::RequestShell, )); self.start_terminal_recording( channel_id, SshRecordingMetadata::Shell { // HACK russh ChannelId is opaque except via Display channel: server_channel_id.0.to_string().parse().unwrap_or_default(), }, ) .await; info!(%channel_id, "Opening shell"); let _ = self .session_handle .as_mut() .context("Invalid session state")? .channel_success(server_channel_id.0) .await; let _ = reply.send(true); } ServerHandlerEvent::AuthPublicKey(username, key, reply) => { let _ = reply.send(self._auth_publickey(username, key).await); } ServerHandlerEvent::AuthPublicKeyOffer(username, key, reply) => { let _ = reply.send(self._auth_publickey_offer(username, key).await); } ServerHandlerEvent::AuthPassword(username, password, reply) => { let _ = reply.send(self._auth_password(username, password).await); } ServerHandlerEvent::AuthKeyboardInteractive(username, response, reply) => { let _ = reply.send(self._auth_keyboard_interactive(username, response).await); } ServerHandlerEvent::Data(channel, data, reply) => { self._data(channel, data).await?; let _ = reply.send(()); } ServerHandlerEvent::ExtendedData(channel, data, code, reply) => { self._extended_data(channel, code, data).await?; let _ = reply.send(()); } ServerHandlerEvent::ChannelClose(channel, reply) => { self._channel_close(channel).await?; let _ = reply.send(()); } ServerHandlerEvent::ChannelEof(channel, reply) => { self._channel_eof(channel).await?; let _ = reply.send(()); } ServerHandlerEvent::WindowChangeRequest(channel, request, reply) => { self._window_change_request(channel, request).await?; let _ = reply.send(()); } ServerHandlerEvent::Signal(channel, signal, reply) => { self._channel_signal(channel, signal).await?; let _ = reply.send(()); } ServerHandlerEvent::ExecRequest(channel, data, reply) => { self._channel_exec_request(channel, data).await?; let _ = reply.send(true); } ServerHandlerEvent::ChannelOpenDirectTcpIp(channel, params, reply) => { let _ = reply.send(self._channel_open_direct_tcpip(channel, params).await?); } ServerHandlerEvent::ChannelOpenDirectStreamlocal(channel, path, reply) => { let _ = reply.send(self._channel_open_direct_streamlocal(channel, path).await?); } ServerHandlerEvent::EnvRequest(channel, name, value, reply) => { self._channel_env_request(channel, name, value).await?; let _ = reply.send(()); } ServerHandlerEvent::X11Request(channel, request, reply) => { self._channel_x11_request(channel, request).await?; let _ = reply.send(()); } ServerHandlerEvent::TcpIpForward(address, port, reply) => { self._tcpip_forward(address, port).await?; let _ = reply.send(true); } ServerHandlerEvent::CancelTcpIpForward(address, port, reply) => { self._cancel_tcpip_forward(address, port).await?; let _ = reply.send(true); } ServerHandlerEvent::StreamlocalForward(socket_path, reply) => { self._streamlocal_forward(socket_path).await?; let _ = reply.send(true); } ServerHandlerEvent::CancelStreamlocalForward(socket_path, reply) => { self._cancel_streamlocal_forward(socket_path).await?; let _ = reply.send(true); } ServerHandlerEvent::AgentForward(channel, reply) => { self._agent_forward(channel).await?; let _ = reply.send(true); } ServerHandlerEvent::Disconnect => (), } Ok(()) } pub async fn handle_session_control(&mut self, command: SessionHandleCommand) -> Result<()> { match command { SessionHandleCommand::Close => { let _ = self.emit_service_message("Session closed by admin").await; info!("Session closed by admin"); self.request_disconnect().await; self.disconnect_server().await; } } Ok(()) } pub async fn handle_remote_event(&mut self, event: RCEvent) -> Result<()> { match event { RCEvent::State(state) => { self.rc_state = state; match &self.rc_state { RCState::Connected => { self.service_output.hide_progress().await; self.service_output.emit_output(Bytes::from(format!( "{}{}\r\n", ERASE_PROGRESS_SPINNER, Colour::Black .on(Colour::Green) .paint(" ✓ Warpgate connected ") ))); } RCState::Disconnected => { self.service_output.hide_progress().await; self.disconnect_server().await; } _ => {} } } RCEvent::ConnectionError(error) => { self.service_output.hide_progress().await; match error { ConnectionError::HostKeyMismatch { received_key_type, received_key_base64, known_key_type, known_key_base64, } => { let msg = format!( concat!( "Host key doesn't match the stored one.\n", "Stored key ({}): {}\n", "Received key ({}): {}", ), known_key_type, known_key_base64, received_key_type, received_key_base64 ); self.emit_service_message(&msg).await?; self.emit_service_message( "If you know that the key is correct (e.g. it has been changed),", ) .await?; self.emit_service_message( "you can remove the old key in the Warpgate management UI and try again", ) .await?; } ConnectionError::Authentication => { self.service_output.emit_output(Bytes::from(format!( "{}{}\r\n", ERASE_PROGRESS_SPINNER, Colour::Black .on(Colour::Red) .paint(" ✗ SSH target rejected Warpgate authentication request ") ))); } error => { self.service_output.emit_output(Bytes::from(format!( "{}{} {}\r\n", ERASE_PROGRESS_SPINNER, Colour::Black.on(Colour::Red).paint(" ✗ Connection failed "), error ))); } } } RCEvent::Error(e) => { self.service_output.hide_progress().await; let _ = self.emit_service_message(&format!("Error: {e}")).await; self.disconnect_server().await; } RCEvent::Output(channel, data) => { if let Some(recorder) = self.channel_recorders.get_mut(&channel) { if let Err(error) = recorder .write(TerminalRecordingStreamId::Output, &data) .await { error!(%channel, ?error, "Failed to record terminal data"); self.channel_recorders.remove(&channel); } } if let Some(recorder) = self.traffic_connection_recorders.get_mut(&channel) { if let Err(error) = recorder.write_rx(&data).await { error!(%channel, ?error, "Failed to record traffic data"); self.traffic_connection_recorders.remove(&channel); } } let server_channel_id = self.map_channel_reverse(&channel)?; if let Some(session) = self.session_handle.clone() { self.channel_writer .write(session, server_channel_id.0, data); } } RCEvent::Success(channel) => { let server_channel_id = self.map_channel_reverse(&channel)?; self.maybe_with_session(|handle| async move { handle .channel_success(server_channel_id.0) .await .context("failed to send data") }) .await?; } RCEvent::ChannelFailure(channel) => { let server_channel_id = self.map_channel_reverse(&channel)?; self.maybe_with_session(|handle| async move { handle .channel_failure(server_channel_id.0) .await .context("failed to send data") }) .await?; } RCEvent::Close(channel) => { // Flush any pending writes before closing the channel let _ = self.channel_writer.flush().await; let server_channel_id = self.map_channel_reverse(&channel)?; let _ = self .maybe_with_session(|handle| async move { handle .close(server_channel_id.0) .await .context("failed to close ch") }) .await; } RCEvent::Eof(channel) => { // Flush any pending writes before sending EOF let _ = self.channel_writer.flush().await; let server_channel_id = self.map_channel_reverse(&channel)?; self.maybe_with_session(|handle| async move { handle .eof(server_channel_id.0) .await .context("failed to send eof") }) .await?; } RCEvent::ExitStatus(channel, code) => { // Flush any pending writes before sending exit status let _ = self.channel_writer.flush().await; let server_channel_id = self.map_channel_reverse(&channel)?; self.maybe_with_session(|handle| async move { handle .exit_status_request(server_channel_id.0, code) .await .context("failed to send exit status") }) .await?; } RCEvent::ExitSignal { channel, signal_name, core_dumped, error_message, lang_tag, } => { let server_channel_id = self.map_channel_reverse(&channel)?; self.maybe_with_session(|handle| async move { handle .exit_signal_request( server_channel_id.0, signal_name, core_dumped, error_message, lang_tag, ) .await .context("failed to send exit status")?; Ok(()) }) .await?; } RCEvent::Done => {} RCEvent::ExtendedData { channel, data, ext } => { if let Some(recorder) = self.channel_recorders.get_mut(&channel) { if let Err(error) = recorder .write(TerminalRecordingStreamId::Error, &data) .await { error!(%channel, ?error, "Failed to record session data"); self.channel_recorders.remove(&channel); } } let server_channel_id = self.map_channel_reverse(&channel)?; if let Some(session) = self.session_handle.clone() { self.channel_writer .write_extended(session, server_channel_id.0, ext, data); } } RCEvent::HostKeyReceived(key) => { self.emit_service_message(&format!( "Host key ({}): {}", key.algorithm(), key.public_key_base64() )) .await?; } RCEvent::HostKeyUnknown(key, reply) => { self.handle_unknown_host_key(key, reply).await?; } RCEvent::ForwardedTcpIp(id, params) => { if let Some(session) = &mut self.session_handle { let server_channel = session .channel_open_forwarded_tcpip( params.connected_address, params.connected_port, params.originator_address.clone(), params.originator_port, ) .await?; self.channel_map .insert(ServerChannelId(server_channel.id()), id); self.all_channels.push(id); let recorder = self .traffic_recorder_for( TrafficRecorderKey::Tcp( params.originator_address.clone(), params.originator_port, ), SshRecordingMetadata::ForwardedTcpIp { host: params.originator_address, port: params.originator_port as u16, }, ) .await; if let Some(recorder) = recorder { #[allow(clippy::unwrap_used)] let mut recorder = recorder.connection(TrafficConnectionParams::Tcp { dst_addr: Ipv4Addr::from_str("2.2.2.2").unwrap(), dst_port: params.connected_port as u16, src_addr: Ipv4Addr::from_str("1.1.1.1").unwrap(), src_port: params.originator_port as u16, }); if let Err(error) = recorder.write_connection_setup().await { error!(channel=%id, ?error, "Failed to record connection setup"); } self.traffic_connection_recorders.insert(id, recorder); } } } RCEvent::ForwardedStreamlocal(id, params) => { if let Some(session) = &mut self.session_handle { let server_channel = session .channel_open_forwarded_streamlocal(params.socket_path.clone()) .await?; self.channel_map .insert(ServerChannelId(server_channel.id()), id); self.all_channels.push(id); let recorder = self .traffic_recorder_for( TrafficRecorderKey::Socket(params.socket_path.clone()), SshRecordingMetadata::ForwardedSocket { path: params.socket_path.clone(), }, ) .await; if let Some(recorder) = recorder { #[allow(clippy::unwrap_used)] let mut recorder = recorder.connection(TrafficConnectionParams::Socket { socket_path: params.socket_path, }); if let Err(error) = recorder.write_connection_setup().await { error!(channel=%id, ?error, "Failed to record connection setup"); } self.traffic_connection_recorders.insert(id, recorder); } } } RCEvent::ForwardedAgent(id) => { if let Some(session) = &mut self.session_handle { let server_channel = session.channel_open_agent().await?; self.channel_map .insert(ServerChannelId(server_channel.id()), id); self.all_channels.push(id); } } RCEvent::X11(id, originator_address, originator_port) => { if let Some(session) = &mut self.session_handle { let server_channel = session .channel_open_x11(originator_address, originator_port) .await?; self.channel_map .insert(ServerChannelId(server_channel.id()), id); self.all_channels.push(id); } } } Ok(()) } async fn handle_unknown_host_key( &mut self, key: PublicKey, reply: oneshot::Sender, ) -> Result<()> { self.service_output.hide_progress().await; let mode = self .services .config .lock() .await .store .ssh .host_key_verification; if mode == SshHostKeyVerificationMode::AutoAccept { let _ = reply.send(true); info!("Accepted untrusted host key (auto-accept is enabled)"); return Ok(()); } if mode == SshHostKeyVerificationMode::AutoReject { let _ = reply.send(false); info!("Rejected untrusted host key (auto-reject is enabled)"); return Ok(()); } if self.pty_channels.is_empty() { warn!("Target host key is not trusted, but there is no active PTY channel to show the trust prompt on."); warn!( "Connect to this target with an interactive session once to accept the host key." ); self.request_disconnect().await; anyhow::bail!("No PTY channel to show an interactive prompt on") } self.emit_service_message(&format!( "There is no trusted {} key for this host.", key.algorithm() )) .await?; self.emit_service_message("Trust this key? (y/n)").await?; let mut sub = self .hub .subscribe(|e| matches!(e, Event::ConsoleInput(_))) .await; let mut service_output = self.service_output.clone(); tokio::spawn(async move { loop { match sub.recv().await { Some(Event::ConsoleInput(data)) => { if data == "y".as_bytes() { let _ = reply.send(true); break; } else if data == "n".as_bytes() { let _ = reply.send(false); break; } } None => break, _ => (), } } service_output.show_progress(); }); Ok(()) } async fn maybe_with_session<'a, FN, FT, R>(&'a mut self, f: FN) -> Result> where FN: FnOnce(&'a mut russh::server::Handle) -> FT + 'a, FT: futures::Future>, { if let Some(handle) = &mut self.session_handle { return Ok(Some(f(handle).await?)); } Ok(None) } async fn _channel_open_direct_tcpip( &mut self, channel: ServerChannelId, params: DirectTCPIPParams, ) -> Result { let uuid = Uuid::new_v4(); self.channel_map.insert(channel, uuid); info!(%channel, "Opening direct TCP/IP channel from {}:{} to {}:{}", params.originator_address, params.originator_port, params.host_to_connect, params.port_to_connect); let _ = self.maybe_connect_remote().await; match self .send_command_and_wait(RCCommand::Channel( uuid, ChannelOperation::OpenDirectTCPIP(params.clone()), )) .await { Ok(()) => { self.all_channels.push(uuid); let recorder = self .traffic_recorder_for( TrafficRecorderKey::Tcp( params.host_to_connect.clone(), params.port_to_connect, ), SshRecordingMetadata::DirectTcpIp { host: params.host_to_connect, port: params.port_to_connect as u16, }, ) .await; if let Some(recorder) = recorder { #[allow(clippy::unwrap_used)] let mut recorder = recorder.connection(TrafficConnectionParams::Tcp { dst_addr: Ipv4Addr::from_str("2.2.2.2").unwrap(), dst_port: params.port_to_connect as u16, src_addr: Ipv4Addr::from_str("1.1.1.1").unwrap(), src_port: params.originator_port as u16, }); if let Err(error) = recorder.write_connection_setup().await { error!(%channel, ?error, "Failed to record connection setup"); } self.traffic_connection_recorders.insert(uuid, recorder); } Ok(true) } Err(SshClientError::Russh(russh::Error::ChannelOpenFailure(_))) => Ok(false), Err(x) => Err(x.into()), } } async fn _channel_open_direct_streamlocal( &mut self, channel: ServerChannelId, path: String, ) -> Result { let uuid = Uuid::new_v4(); self.channel_map.insert(channel, uuid); info!(%channel, "Opening direct streamlocal channel to {}", path); let _ = self.maybe_connect_remote().await; match self .send_command_and_wait(RCCommand::Channel( uuid, ChannelOperation::OpenDirectStreamlocal(path.clone()), )) .await { Ok(()) => { self.all_channels.push(uuid); let recorder = self .traffic_recorder_for( TrafficRecorderKey::Socket(path.clone()), SshRecordingMetadata::DirectSocket { path: path.clone() }, ) .await; if let Some(recorder) = recorder { #[allow(clippy::unwrap_used)] let mut recorder = recorder.connection(TrafficConnectionParams::Socket { socket_path: path }); if let Err(error) = recorder.write_connection_setup().await { error!(%channel, ?error, "Failed to record connection setup"); } self.traffic_connection_recorders.insert(uuid, recorder); } Ok(true) } Err(SshClientError::Russh(russh::Error::ChannelOpenFailure(_))) => Ok(false), Err(x) => Err(x.into()), } } async fn _window_change_request( &mut self, server_channel_id: ServerChannelId, request: PtyRequest, ) -> Result<()> { let channel_id = self.map_channel(&server_channel_id)?; self.channel_pty_size_map .insert(channel_id, request.clone()); if let Some(recorder) = self.channel_recorders.get_mut(&channel_id) { if let Err(error) = recorder .write_pty_resize(request.col_width, request.row_height) .await { error!(%channel_id, ?error, "Failed to record terminal data"); self.channel_recorders.remove(&channel_id); } } self.send_command_and_wait(RCCommand::Channel( channel_id, ChannelOperation::ResizePty(request), )) .await?; Ok(()) } async fn _channel_exec_request( &mut self, server_channel_id: ServerChannelId, data: Bytes, ) -> Result<()> { let channel_id = self.map_channel(&server_channel_id)?; match std::str::from_utf8(&data) { Err(e) => { error!(channel=%channel_id, ?data, "Requested exec - invalid UTF-8"); anyhow::bail!(e) } Ok::<&str, _>(command) => { debug!(channel=%channel_id, %command, "Requested exec"); let _ = self.maybe_connect_remote().await; let _ = self.send_command(RCCommand::Channel( channel_id, ChannelOperation::RequestExec(command.to_string()), )); } } self.start_terminal_recording( channel_id, SshRecordingMetadata::Exec { // HACK russh ChannelId is opaque except via Display channel: server_channel_id.0.to_string().parse().unwrap_or_default(), }, ) .await; Ok(()) } async fn start_terminal_recording(&mut self, channel_id: Uuid, metadata: SshRecordingMetadata) { let recorder = async { let mut recorder = self .services .recordings .lock() .await .start::(&self.id, None, metadata) .await?; if let Some(request) = self.channel_pty_size_map.get(&channel_id) { recorder .write_pty_resize(request.col_width, request.row_height) .await?; } Ok::<_, recordings::Error>(recorder) } .await; match recorder { Ok(recorder) => { self.channel_recorders.insert(channel_id, recorder); } Err(error) => match error { recordings::Error::Disabled => (), error => error!(channel=%channel_id, ?error, "Failed to start recording"), }, } } async fn _channel_x11_request( &mut self, server_channel_id: ServerChannelId, request: X11Request, ) -> Result<()> { let channel_id = self.map_channel(&server_channel_id)?; debug!(channel=%channel_id, "Requested X11"); let _ = self.maybe_connect_remote().await; self.send_command_and_wait(RCCommand::Channel( channel_id, ChannelOperation::RequestX11(request), )) .await?; Ok(()) } async fn _channel_env_request( &mut self, server_channel_id: ServerChannelId, name: String, value: String, ) -> Result<()> { let channel_id = self.map_channel(&server_channel_id)?; debug!(channel=%channel_id, %name, %value, "Environment"); self.send_command_and_wait(RCCommand::Channel( channel_id, ChannelOperation::RequestEnv(name, value), )) .await?; Ok(()) } async fn traffic_recorder_for( &mut self, key: TrafficRecorderKey, metadata: SshRecordingMetadata, ) -> Option<&mut TrafficRecorder> { if let Vacant(e) = self.traffic_recorders.entry(key.clone()) { match self .services .recordings .lock() .await .start(&self.id, None, metadata) .await { Ok(recorder) => { e.insert(recorder); } Err(error) => { error!(?key, ?error, "Failed to start recording"); } } } self.traffic_recorders.get_mut(&key) } pub async fn _channel_subsystem_request( &mut self, server_channel_id: ServerChannelId, name: String, ) -> Result<(), SshClientError> { let channel_id = self.map_channel(&server_channel_id)?; info!(channel=%channel_id, "Requesting subsystem {}", &name); let _ = self.maybe_connect_remote().await; self.send_command_and_wait(RCCommand::Channel( channel_id, ChannelOperation::RequestSubsystem(name), )) .await?; Ok(()) } async fn _data(&mut self, server_channel_id: ServerChannelId, data: Bytes) -> Result<()> { let channel_id = self.map_channel(&server_channel_id)?; debug!(channel=%server_channel_id.0, ?data, "Data"); if self.rc_state == RCState::Connecting && data.first() == Some(&3) { info!(channel=%channel_id, "User requested connection abort (Ctrl-C)"); self.request_disconnect().await; return Ok(()); } if let Some(recorder) = self.channel_recorders.get_mut(&channel_id) { if let Err(error) = recorder .write(TerminalRecordingStreamId::Input, &data) .await { error!(channel=%channel_id, ?error, "Failed to record terminal data"); self.channel_recorders.remove(&channel_id); } } if let Some(recorder) = self.traffic_connection_recorders.get_mut(&channel_id) { if let Err(error) = recorder.write_tx(&data).await { error!(channel=%channel_id, ?error, "Failed to record traffic data"); self.traffic_connection_recorders.remove(&channel_id); } } if self.pty_channels.contains(&channel_id) { let _ = self .event_sender .send_once(Event::ConsoleInput(data.clone())) .await; } let _ = self.send_command(RCCommand::Channel(channel_id, ChannelOperation::Data(data))); Ok(()) } async fn _extended_data( &mut self, server_channel_id: ServerChannelId, code: u32, data: Bytes, ) -> Result<()> { let channel_id = self.map_channel(&server_channel_id)?; debug!(channel=%server_channel_id.0, ?data, "Data"); let _ = self.send_command(RCCommand::Channel( channel_id, ChannelOperation::ExtendedData { ext: code, data }, )); Ok(()) } async fn _tcpip_forward(&mut self, address: String, port: u32) -> Result<()> { info!(%address, %port, "Remote port forwarding requested"); let _ = self.maybe_connect_remote().await; self.send_command_and_wait(RCCommand::ForwardTCPIP(address, port)) .await .map_err(anyhow::Error::from) } pub async fn _cancel_tcpip_forward(&mut self, address: String, port: u32) -> Result<()> { info!(%address, %port, "Remote port forwarding cancelled"); self.send_command_and_wait(RCCommand::CancelTCPIPForward(address, port)) .await .map_err(anyhow::Error::from) } async fn _streamlocal_forward(&mut self, socket_path: String) -> Result<()> { info!(%socket_path, "Remote UNIX socket forwarding requested"); let _ = self.maybe_connect_remote().await; self.send_command_and_wait(RCCommand::StreamlocalForward(socket_path)) .await .map_err(anyhow::Error::from) } pub async fn _cancel_streamlocal_forward(&mut self, socket_path: String) -> Result<()> { info!(%socket_path, "Remote UNIX socket forwarding cancelled"); self.send_command_and_wait(RCCommand::CancelStreamlocalForward(socket_path)) .await .map_err(anyhow::Error::from) } async fn _agent_forward(&mut self, server_channel_id: ServerChannelId) -> Result<()> { let channel_id = self.map_channel(&server_channel_id)?; debug!(channel=%channel_id, "Requested Agent Forwarding"); self.send_command_and_wait(RCCommand::Channel( channel_id, ChannelOperation::AgentForward, )) .await?; Ok(()) } async fn _auth_publickey_offer( &mut self, ssh_username: Secret, key: PublicKey, ) -> russh::server::Auth { let selector: AuthSelector = ssh_username.expose_secret().into(); info!( "Client offers public key auth as {selector:?} with key {}", key.public_key_base64() ); if !self.allowed_auth_methods.contains(&MethodKind::PublicKey) { warn!("Client attempted public key auth even though it was not advertised"); return russh::server::Auth::reject(); } if let Ok(true) = self .try_validate_public_key_offer( &selector, Some(AuthCredential::PublicKey { kind: key.algorithm(), public_key_bytes: Bytes::from(key.public_key_bytes()), }), ) .await { return russh::server::Auth::Accept; } let selector: AuthSelector = ssh_username.expose_secret().into(); match self.try_auth_lazy(&selector, None).await { Ok(AuthResult::Need(kinds)) => russh::server::Auth::Reject { proceed_with_methods: Some(self.get_remaining_auth_methods(kinds)), partial_success: false, }, _ => russh::server::Auth::reject(), } } async fn _auth_publickey( &mut self, ssh_username: Secret, key: PublicKey, ) -> russh::server::Auth { let selector: AuthSelector = ssh_username.expose_secret().into(); info!( "Public key auth as {selector:?} with key {}", key.public_key_base64() ); if !self.allowed_auth_methods.contains(&MethodKind::PublicKey) { warn!("Client attempted public key auth even though it was not advertised"); return russh::server::Auth::reject(); } let key = Some(AuthCredential::PublicKey { kind: key.algorithm(), public_key_bytes: Bytes::from(key.public_key_bytes()), }); let result = self.try_auth_lazy(&selector, key.clone()).await; match result { Ok(AuthResult::Accepted { .. }) => { // Update last_used timestamp if let Err(err) = self .services .config_provider .lock() .await .update_public_key_last_used(key.clone()) .await { warn!(?err, "Failed to update last_used for public key"); } russh::server::Auth::Accept } Ok(AuthResult::Rejected) => russh::server::Auth::Reject { proceed_with_methods: Some(MethodSet::all()), partial_success: false, }, Ok(AuthResult::Need(kinds)) => russh::server::Auth::Reject { proceed_with_methods: Some(self.get_remaining_auth_methods(kinds)), partial_success: false, }, Err(error) => { error!(?error, "Failed to verify credentials"); russh::server::Auth::Reject { proceed_with_methods: None, partial_success: false, } } } } async fn _auth_password( &mut self, ssh_username: Secret, password: Secret, ) -> russh::server::Auth { let selector: AuthSelector = ssh_username.expose_secret().into(); info!("Password auth as {selector:?}"); if !self.allowed_auth_methods.contains(&MethodKind::Password) { warn!("Client attempted password auth even though it was not advertised"); return russh::server::Auth::reject(); } match self .try_auth_lazy(&selector, Some(AuthCredential::Password(password))) .await { Ok(AuthResult::Accepted { .. }) => russh::server::Auth::Accept, Ok(AuthResult::Rejected) => russh::server::Auth::reject(), Ok(AuthResult::Need(kinds)) => russh::server::Auth::Reject { proceed_with_methods: Some(self.get_remaining_auth_methods(kinds)), partial_success: false, }, Err(error) => { error!(?error, "Failed to verify credentials"); russh::server::Auth::Reject { proceed_with_methods: None, partial_success: false, } } } } async fn _auth_keyboard_interactive( &mut self, ssh_username: Secret, response: Option>, ) -> russh::server::Auth { let selector: AuthSelector = ssh_username.expose_secret().into(); info!("Keyboard-interactive auth as {:?}", selector); if !self .allowed_auth_methods .contains(&MethodKind::KeyboardInteractive) { warn!("Client attempted keyboard-interactive auth even though it was not advertised"); return russh::server::Auth::reject(); } let cred; match &mut self.keyboard_interactive_state { KeyboardInteractiveState::None => { cred = None; } KeyboardInteractiveState::OtpRequested => { cred = response.map(AuthCredential::Otp); } KeyboardInteractiveState::WebAuthRequested(event) => { cred = None; let _ = event.recv().await; // the auth state has been updated by now } } self.keyboard_interactive_state = KeyboardInteractiveState::None; match self.try_auth_lazy(&selector, cred).await { Ok(AuthResult::Accepted { .. }) => russh::server::Auth::Accept, Ok(AuthResult::Rejected) => russh::server::Auth::reject(), Ok(AuthResult::Need(kinds)) => { if kinds.contains(&CredentialKind::Totp) { self.keyboard_interactive_state = KeyboardInteractiveState::OtpRequested; russh::server::Auth::Partial { name: Cow::Borrowed("Two-factor authentication"), instructions: Cow::Borrowed(""), prompts: Cow::Owned(vec![(Cow::Borrowed("One-time password: "), true)]), } } else if kinds.contains(&CredentialKind::WebUserApproval) { let Some(auth_state) = self.auth_state.as_ref() else { return russh::server::Auth::Reject { proceed_with_methods: None, partial_success: false, }; }; let identification_string = auth_state.lock().await.identification_string().to_owned(); let auth_state_id = *auth_state.lock().await.id(); let event = self .services .auth_state_store .lock() .await .subscribe(auth_state_id); self.keyboard_interactive_state = KeyboardInteractiveState::WebAuthRequested(event); let login_url = match auth_state .lock() .await .construct_web_approval_url(&*self.services.config.lock().await) { Ok(login_url) => login_url, Err(error) => { error!(?error, "Failed to construct external URL"); return russh::server::Auth::Reject { proceed_with_methods: None, partial_success: false, }; } }; russh::server::Auth::Partial { name: Cow::Borrowed("Warpgate authentication"), instructions: Cow::Owned(format!( concat!( "-----------------------------------------------------------------------\n", "Warpgate authentication: please open the following URL in your browser:\n", "{}\n\n", "Make sure you're seeing this security key: {}\n", "-----------------------------------------------------------------------\n" ), login_url, identification_string .chars() .map(|x| x.to_string()) .collect::>() .join(" ") )), prompts: Cow::Owned(vec![(Cow::Borrowed("Press Enter when done: "), true)]), } } else { russh::server::Auth::Reject { proceed_with_methods: None, partial_success: false, } } } Err(error) => { error!(?error, "Failed to verify credentials"); russh::server::Auth::Reject { proceed_with_methods: None, partial_success: false, } } } } fn get_remaining_auth_methods(&self, kinds: HashSet) -> MethodSet { let mut m = MethodSet::empty(); for cred_kind in kinds { let method_kind = match cred_kind { CredentialKind::Password => MethodKind::Password, CredentialKind::Totp => MethodKind::KeyboardInteractive, CredentialKind::WebUserApproval => MethodKind::KeyboardInteractive, CredentialKind::PublicKey => MethodKind::PublicKey, CredentialKind::Sso => MethodKind::KeyboardInteractive, CredentialKind::Certificate => { // Certificate authentication is not supported for SSH protocol // This credential type is primarily for Kubernetes continue; } }; if self.allowed_auth_methods.contains(&method_kind) { m.push(method_kind); } } if m.contains(&MethodKind::KeyboardInteractive) { // Ensure keyboard-interactive is always the last method m.push(MethodKind::KeyboardInteractive); } m } async fn try_validate_public_key_offer( &mut self, selector: &AuthSelector, credential: Option, ) -> Result { match selector { AuthSelector::User { username, .. } => { let cp = self.services.config_provider.clone(); if let Some(credential) = credential { return Ok(cp .lock() .await .validate_credential(username, &credential) .await?); } Ok(false) } _ => Ok(false), } } /// As try_auth_lazy is called multiple times, this memoization prevents /// consuming the ticket multiple times, depleting its uses. async fn try_auth_lazy( &mut self, selector: &AuthSelector, credential: Option, ) -> Result { if let AuthSelector::Ticket { secret } = selector { if let Some(ref csta) = self.cached_successful_ticket_auth { // Only if the client hasn't maliciously changed the username // between auth attempts if &csta.ticket == secret { return Ok(AuthResult::Accepted { user_info: csta.user_info.clone(), }); } } let result = self.try_auth_eager(selector, credential).await?; if let AuthResult::Accepted { ref user_info } = result { self.cached_successful_ticket_auth = Some(CachedSuccessfulTicketAuth { ticket: secret.clone(), user_info: user_info.clone(), }); } return Ok(result); } self.try_auth_eager(selector, credential).await } async fn try_auth_eager( &mut self, selector: &AuthSelector, credential: Option, ) -> Result { match selector { AuthSelector::User { username, target_name, } => { let cp = self.services.config_provider.clone(); let state_arc = self.get_auth_state(username).await?; let mut state = state_arc.lock().await; if let Some(credential) = credential { if cp .lock() .await .validate_credential(username, &credential) .await? { state.add_valid_credential(credential); } } let user_auth_result = state.verify(); match user_auth_result { AuthResult::Accepted { user_info } => { self.services .auth_state_store .lock() .await .complete(state.id()) .await; let target_auth_result = { self.services .config_provider .lock() .await .authorize_target(&user_info.username, target_name) .await? }; if !target_auth_result { warn!( "Target {} not authorized for user {}", target_name, username ); return Ok(AuthResult::Rejected); } self._auth_accept(user_info.clone(), target_name).await?; Ok(AuthResult::Accepted { user_info }) } x => Ok(x), } } AuthSelector::Ticket { secret } => { match authorize_ticket(&self.services.db, secret).await? { Some((ticket, user_info)) => { info!("Authorized for {} with a ticket", ticket.target); consume_ticket(&self.services.db, &ticket.id).await?; self._auth_accept(user_info.clone(), &ticket.target).await?; Ok(AuthResult::Accepted { user_info }) } None => Ok(AuthResult::Rejected), } } } } async fn _auth_accept( &mut self, user_info: AuthStateUserInfo, target_name: &str, ) -> Result<(), WarpgateError> { self.username = Some(user_info.username.clone()); let _ = self .server_handle .lock() .await .set_user_info(user_info.clone()) .await; let target = { self.services .config_provider .lock() .await .list_targets() .await? .iter() .filter_map(|t| match t.options { TargetOptions::Ssh(ref options) => Some((t, options)), _ => None, }) .find(|(t, _)| t.name == target_name) .map(|(t, opt)| (t.clone(), opt.clone())) }; let Some((target, mut ssh_options)) = target else { self.target = TargetSelection::NotFound(target_name.to_string()); warn!("Selected target not found"); return Ok(()); }; // Forward username from the authenticated user to the target, if target has no username if ssh_options.username.is_empty() { ssh_options.username = user_info.username.to_string(); } let _ = self.server_handle.lock().await.set_target(&target).await; self.target = TargetSelection::Found(target, ssh_options); Ok(()) } async fn _channel_close(&mut self, server_channel_id: ServerChannelId) -> Result<()> { let channel_id = self.map_channel(&server_channel_id)?; debug!(channel=%channel_id, "Closing channel"); self.send_command_and_wait(RCCommand::Channel(channel_id, ChannelOperation::Close)) .await?; Ok(()) } async fn _channel_eof(&mut self, server_channel_id: ServerChannelId) -> Result<()> { let channel_id = self.map_channel(&server_channel_id)?; debug!(channel=%channel_id, "EOF"); let _ = self.send_command(RCCommand::Channel(channel_id, ChannelOperation::Eof)); Ok(()) } pub async fn _channel_signal( &mut self, server_channel_id: ServerChannelId, signal: Sig, ) -> Result<()> { let channel_id = self.map_channel(&server_channel_id)?; debug!(channel=%channel_id, ?signal, "Signal"); self.send_command_and_wait(RCCommand::Channel( channel_id, ChannelOperation::Signal(signal), )) .await?; Ok(()) } fn send_command(&mut self, command: RCCommand) -> Result<(), RCCommand> { self.rc_tx.send((command, None)).map_err(|e| e.0 .0) } async fn send_command_and_wait(&mut self, command: RCCommand) -> Result<(), SshClientError> { let (tx, rx) = oneshot::channel(); let mut cmd = match self.rc_tx.send((command, Some(tx))) { Ok(_) => PendingCommand::Waiting(rx), Err(_) => PendingCommand::Failed, }; loop { tokio::select! { result = &mut cmd => { return result } event = self.get_next_event() => { match event { Some(event) => { self.handle_event(event).await.map_err(SshClientError::from)? } None => {Err(SshClientError::MpscError)?} }; } } } } pub async fn _disconnect(&mut self) { debug!("Client disconnect requested"); self.request_disconnect().await; } async fn request_disconnect(&mut self) { debug!("Disconnecting"); let _ = self.rc_abort_tx.send(()); if self.rc_state != RCState::NotInitialized && self.rc_state != RCState::Disconnected { let _ = self.send_command(RCCommand::Disconnect); } } async fn disconnect_server(&mut self) { let all_channels = std::mem::take(&mut self.all_channels); let channels = all_channels .into_iter() .map(|x| self.map_channel_reverse(&x)) .filter_map(|x| x.ok()) .collect::>(); let _ = self .maybe_with_session(|handle| async move { for ch in channels { let _ = handle.close(ch.0).await; } Ok(()) }) .await; self.session_handle = None; } } impl Drop for ServerSession { fn drop(&mut self) { let _ = self.rc_abort_tx.send(()); info!("Closed session"); debug!("Dropped"); } } pub enum PendingCommand { Waiting(oneshot::Receiver>), Failed, } impl Future for PendingCommand { type Output = Result<(), SshClientError>; fn poll(self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll { match self.get_mut() { PendingCommand::Waiting(ref mut rx) => match Pin::new(rx).poll(cx) { Poll::Ready(result) => { Poll::Ready(result.unwrap_or(Err(SshClientError::MpscError))) } Poll::Pending => Poll::Pending, }, PendingCommand::Failed => Poll::Ready(Err(SshClientError::MpscError)), } } } ================================================ FILE: warpgate-protocol-ssh/src/server/session_handle.rs ================================================ use tokio::sync::mpsc; use warpgate_core::SessionHandle; #[derive(Clone, Debug, PartialEq, Eq)] pub enum SessionHandleCommand { Close, } pub struct SSHSessionHandle { sender: mpsc::UnboundedSender, } impl SSHSessionHandle { pub fn new() -> (Self, mpsc::UnboundedReceiver) { let (sender, receiver) = mpsc::unbounded_channel(); (SSHSessionHandle { sender }, receiver) } } impl SessionHandle for SSHSessionHandle { fn close(&mut self) { let _ = self.sender.send(SessionHandleCommand::Close); } } ================================================ FILE: warpgate-sso/Cargo.toml ================================================ [package] edition = "2021" license = "Apache-2.0" name = "warpgate-sso" version = "0.22.0" [dependencies] bytes.workspace = true thiserror.workspace = true tokio.workspace = true tracing.workspace = true openidconnect = { version = "4.0", default-features = false, features = [ "reqwest", "accept-string-booleans", ] } yup-oauth2 = "12" reqwest_12.workspace = true serde.workspace = true serde_json.workspace = true once_cell = { version = "1.17", default-features = false } jsonwebtoken = { version = "9", default-features = false, features = ["use_pem"] } data-encoding.workspace = true futures.workspace = true schemars.workspace = true ================================================ FILE: warpgate-sso/src/config.rs ================================================ use std::collections::HashMap; use std::fmt::{Display, Formatter}; use std::time::SystemTime; use data_encoding::BASE64; use once_cell::sync::Lazy; use openidconnect::{AuthType, ClientId, ClientSecret, IssuerUrl}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::SsoError; /// A role mapping value that accepts either a single role or a list of roles. /// In YAML config: `"group": "role"` or `"group": ["role1", "role2"]` #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(untagged)] pub enum RoleMapping { Single(String), Multiple(Vec), } impl RoleMapping { pub fn roles(&self) -> Vec { match self { RoleMapping::Single(s) => vec![s.clone()], RoleMapping::Multiple(v) => v.clone(), } } } #[allow(clippy::unwrap_used)] pub static GOOGLE_ISSUER_URL: Lazy = Lazy::new(|| IssuerUrl::new("https://accounts.google.com".to_string()).unwrap()); #[allow(clippy::unwrap_used)] pub static APPLE_ISSUER_URL: Lazy = Lazy::new(|| IssuerUrl::new("https://appleid.apple.com".to_string()).unwrap()); #[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema)] pub enum SsoProviderReturnUrlPrefix { #[serde(rename = "@")] #[default] AtSign, #[serde(rename = "_")] Underscore, } impl Display for SsoProviderReturnUrlPrefix { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { SsoProviderReturnUrlPrefix::AtSign => write!(f, "@"), SsoProviderReturnUrlPrefix::Underscore => write!(f, "_"), } } } #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct SsoProviderConfig { pub name: String, pub label: Option, pub provider: SsoInternalProviderConfig, pub return_domain_whitelist: Option>, #[serde(default)] pub return_url_prefix: SsoProviderReturnUrlPrefix, #[serde(default)] pub auto_create_users: bool, /// Default credential policy for auto-created users. /// Keys: "http", "ssh", "mysql", "postgres" /// Values: list of credential kinds e.g. ["sso"], ["web"], [] pub default_credential_policy: Option, } impl SsoProviderConfig { pub fn label(&self) -> &str { self.label .as_deref() .unwrap_or_else(|| self.provider.label()) } } #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(tag = "type")] pub enum SsoInternalProviderConfig { #[serde(rename = "google")] Google { #[schemars(with = "String")] client_id: ClientId, #[schemars(with = "String")] client_secret: ClientSecret, /// Service account email for Google Directory API group lookups service_account_email: Option, /// PEM private key from the service account JSON key file service_account_key: Option, /// A Google Workspace admin email for domain-wide delegation admin_email: Option, /// Maps Google group email addresses to Warpgate role names. /// Use "*" as a key to set a default role for any group not explicitly mapped. role_mappings: Option>, admin_role_mappings: Option>, }, #[serde(rename = "apple")] Apple { #[schemars(with = "String")] client_id: ClientId, #[schemars(with = "String")] client_secret: ClientSecret, key_id: String, team_id: String, }, #[serde(rename = "azure")] Azure { #[schemars(with = "String")] client_id: ClientId, #[schemars(with = "String")] client_secret: ClientSecret, tenant: String, }, #[serde(rename = "custom")] Custom { #[schemars(with = "String")] client_id: ClientId, #[schemars(with = "String")] client_secret: ClientSecret, #[schemars(with = "String")] issuer_url: IssuerUrl, scopes: Vec, role_mappings: Option>, admin_role_mappings: Option>, additional_trusted_audiences: Option>, #[serde(default)] trust_unknown_audiences: bool, }, } #[derive(Debug, Serialize)] struct AppleIDClaims<'a> { sub: &'a str, aud: &'a str, exp: usize, nbf: usize, iss: &'a str, } impl SsoInternalProviderConfig { #[inline] pub fn label(&self) -> &'static str { match self { SsoInternalProviderConfig::Google { .. } => "Google", SsoInternalProviderConfig::Apple { .. } => "Apple", SsoInternalProviderConfig::Azure { .. } => "Azure", SsoInternalProviderConfig::Custom { .. } => "SSO", } } #[inline] pub fn client_id(&self) -> &ClientId { match self { SsoInternalProviderConfig::Google { client_id, .. } | SsoInternalProviderConfig::Apple { client_id, .. } | SsoInternalProviderConfig::Azure { client_id, .. } | SsoInternalProviderConfig::Custom { client_id, .. } => client_id, } } #[inline] pub fn client_secret(&self) -> Result { Ok(match self { SsoInternalProviderConfig::Google { client_secret, .. } | SsoInternalProviderConfig::Azure { client_secret, .. } | SsoInternalProviderConfig::Custom { client_secret, .. } => client_secret.clone(), SsoInternalProviderConfig::Apple { client_secret, client_id, key_id, team_id, } => { let key_content = BASE64 .decode(client_secret.secret().as_bytes()) .map_err(|e| { SsoError::ConfigError(format!( "could not decode base64 client_secret: {e}" )) })?; let key = jsonwebtoken::EncodingKey::from_ec_pem(&key_content).map_err(|e| { SsoError::ConfigError(format!( "could not parse client_secret as a private key: {e}" )) })?; let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256); header.kid = Some(key_id.into()); #[allow(clippy::unwrap_used)] ClientSecret::new(jsonwebtoken::encode( &header, &AppleIDClaims { aud: &APPLE_ISSUER_URL, sub: client_id, exp: SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap() .as_secs() as usize + 600, nbf: SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap() .as_secs() as usize, iss: team_id, }, &key, )?) } }) } #[inline] pub fn issuer_url(&self) -> Result { Ok(match self { SsoInternalProviderConfig::Google { .. } => GOOGLE_ISSUER_URL.clone(), SsoInternalProviderConfig::Apple { .. } => APPLE_ISSUER_URL.clone(), SsoInternalProviderConfig::Azure { tenant, .. } => { IssuerUrl::new(format!("https://login.microsoftonline.com/{tenant}/v2.0"))? } SsoInternalProviderConfig::Custom { issuer_url, .. } => { let mut url = issuer_url.url().clone(); let path = url.path().to_owned(); if let Some(path) = path.strip_suffix("/.well-known/openid-configuration") { url.set_path(path); let url_string = url.to_string(); IssuerUrl::new(url_string.trim_end_matches('/').into())? } else { issuer_url.clone() } } }) } #[inline] pub fn scopes(&self) -> Vec { match self { SsoInternalProviderConfig::Google { .. } | SsoInternalProviderConfig::Azure { .. } => { vec!["email".into(), "profile".into()] } SsoInternalProviderConfig::Custom { scopes, .. } => scopes.clone(), SsoInternalProviderConfig::Apple { .. } => vec![], } } #[inline] pub fn extra_parameters(&self) -> HashMap { match self { SsoInternalProviderConfig::Apple { .. } => { let mut map = HashMap::new(); map.insert("response_mode".to_string(), "form_post".to_string()); map } _ => HashMap::new(), } } #[inline] pub fn auth_type(&self) -> AuthType { #[allow(clippy::match_like_matches_macro)] match self { SsoInternalProviderConfig::Apple { .. } => AuthType::RequestBody, _ => AuthType::BasicAuth, } } #[inline] pub fn needs_pkce_verifier(&self) -> bool { #[allow(clippy::match_like_matches_macro)] match self { SsoInternalProviderConfig::Apple { .. } => false, _ => true, } } #[inline] pub fn role_mappings(&self) -> Option> { #[allow(clippy::match_like_matches_macro)] match self { SsoInternalProviderConfig::Google { role_mappings, .. } | SsoInternalProviderConfig::Custom { role_mappings, .. } => role_mappings.clone(), _ => None, } } #[inline] pub fn admin_role_mappings(&self) -> Option> { #[allow(clippy::match_like_matches_macro)] match self { SsoInternalProviderConfig::Google { admin_role_mappings, .. } | SsoInternalProviderConfig::Custom { admin_role_mappings, .. } => admin_role_mappings.clone(), _ => None, } } #[inline] pub fn additional_trusted_audiences(&self) -> Option<&Vec> { #[allow(clippy::match_like_matches_macro)] match self { SsoInternalProviderConfig::Custom { additional_trusted_audiences, .. } => additional_trusted_audiences.as_ref(), _ => None, } } #[inline] pub fn trust_unknown_audiences(&self) -> bool { #[allow(clippy::match_like_matches_macro)] match self { SsoInternalProviderConfig::Custom { trust_unknown_audiences, .. } => *trust_unknown_audiences, _ => false, } } } ================================================ FILE: warpgate-sso/src/error.rs ================================================ use std::error::Error; use openidconnect::{ reqwest, ClaimsVerificationError, ConfigurationError, SignatureVerificationError, SigningError, }; #[derive(thiserror::Error, Debug)] pub enum SsoError { #[error("provider is OAuth2, not OIDC")] NotOidc, #[error("the token was replaced in flight")] Mitm, #[error("config parse error: {0}")] UrlParse(#[from] openidconnect::url::ParseError), #[error("config error: {0}")] ConfigError(String), #[error("provider discovery error: {0}")] Discovery(String), #[error("code verification error: {0}")] Verification(String), #[error("claims verification error: {0}")] ClaimsVerification(#[from] ClaimsVerificationError), #[error("signing error: {0}")] Signing(#[from] SigningError), #[error("reqwest: {0}")] Reqwest(#[from] reqwest::Error), #[error("I/O: {0}")] Io(#[from] std::io::Error), #[error("JWT error: {0}")] Jwt(#[from] jsonwebtoken::errors::Error), #[error("signature verification: {0}")] SignatureVerification(#[from] SignatureVerificationError), #[error("configuration: {0}")] Configuration(#[from] ConfigurationError), #[error("Google Directory API error: {0}")] GoogleDirectory(String), #[error("the OIDC provider doesn't support RP-initiated logout")] LogoutNotSupported, #[error(transparent)] Other(Box), } ================================================ FILE: warpgate-sso/src/google_groups.rs ================================================ use openidconnect::reqwest; use serde::Deserialize; use tracing::{debug, warn}; use yup_oauth2::{ServiceAccountAuthenticator, ServiceAccountKey}; use crate::config::SsoInternalProviderConfig; use crate::SsoError; #[derive(Debug, Deserialize)] struct DirectoryGroupsResponse { #[serde(default)] groups: Vec, #[serde(rename = "nextPageToken")] next_page_token: Option, } #[derive(Debug, Deserialize)] struct DirectoryGroup { email: String, } const DIRECTORY_SCOPE: &str = "https://www.googleapis.com/auth/admin.directory.group.readonly"; const GOOGLE_TOKEN_URL: &str = "https://oauth2.googleapis.com/token"; const DIRECTORY_GROUPS_URL: &str = "https://admin.googleapis.com/admin/directory/v1/groups"; /// Fetches the user's Google Workspace group memberships via the Directory API. /// /// Requires `service_account_email`, `service_account_key`, and `admin_email` /// to be configured on the Google SSO provider. These values should be /// resolved by the deployment environment before Warpgate reads the config. /// /// Returns `Ok(None)` if not a Google provider or service account is not configured. pub async fn fetch_groups_if_configured( config: &SsoInternalProviderConfig, user_email: Option<&str>, ) -> Result>, SsoError> { let SsoInternalProviderConfig::Google { service_account_email: Some(ref sa_email), service_account_key: Some(ref sa_key), admin_email: Some(ref admin_email), .. } = config else { return Ok(None); }; let Some(user_email) = user_email else { warn!("Google group sync configured but user email not available from OIDC claims"); return Ok(None); }; debug!("Fetching Google groups for {user_email}"); let http_client = reqwest::ClientBuilder::new().build()?; let access_token = get_access_token(sa_email, sa_key, admin_email).await?; let groups = fetch_user_groups(&http_client, &access_token, user_email).await?; debug!("Google groups for {user_email}: {groups:?}"); Ok(Some(groups)) } async fn parse_json_response( response: reqwest::Response, context: &str, ) -> Result { let body = response .text() .await .map_err(|e| SsoError::GoogleDirectory(format!("{context} read failed: {e}")))?; serde_json::from_str(&body) .map_err(|e| SsoError::GoogleDirectory(format!("{context} parse failed: {e}"))) } async fn get_access_token( service_account_email: &str, private_key_pem: &str, admin_email: &str, ) -> Result { let key = ServiceAccountKey { key_type: None, project_id: None, private_key_id: None, private_key: private_key_pem.to_string(), client_email: service_account_email.to_string(), client_id: None, auth_uri: None, token_uri: GOOGLE_TOKEN_URL.to_string(), auth_provider_x509_cert_url: None, client_x509_cert_url: None, }; let auth = ServiceAccountAuthenticator::builder(key) .subject(admin_email.to_string()) .build() .await .map_err(|e| SsoError::GoogleDirectory(format!("authenticator init failed: {e}")))?; let token = auth .token(&[DIRECTORY_SCOPE]) .await .map_err(|e| SsoError::GoogleDirectory(format!("service account token request failed: {e}"))).inspect_err(|_| { warn!("Ensure that domain-wide delegation is enabled for your service account's client ID with a {DIRECTORY_SCOPE} scope and that Admin SDK API is enabled for your Google Cloud project: https://console.cloud.google.com/apis/library/admin.googleapis.com"); })?; Ok(token .token() .ok_or(SsoError::GoogleDirectory("no access token received".into()))? .to_string()) } async fn fetch_user_groups( http_client: &reqwest::Client, access_token: &str, user_email: &str, ) -> Result, SsoError> { let mut all_groups = Vec::new(); let mut page_token: Option = None; loop { let mut req = http_client .get(DIRECTORY_GROUPS_URL) .bearer_auth(access_token) .query(&[("userKey", user_email)]); if let Some(ref token) = page_token { req = req.query(&[("pageToken", token.as_str())]); } let response = req .send() .await .map_err(|e| SsoError::GoogleDirectory(format!("group lookup failed: {e}")))?; if response.status() != 200 { return Err(SsoError::GoogleDirectory(format!( "Google group lookup failed with status {}: {}", response.status(), response.text().await.unwrap_or_default() ))); } let response = response .error_for_status() .map_err(|e| SsoError::GoogleDirectory(format!("group lookup failed: {e}")))?; let resp: DirectoryGroupsResponse = parse_json_response(response, "group response").await?; all_groups.extend(resp.groups.into_iter().map(|g| g.email)); if let Some(token) = resp.next_page_token { if !token.is_empty() { page_token = Some(token); continue; } } break; } Ok(all_groups) } ================================================ FILE: warpgate-sso/src/lib.rs ================================================ mod config; mod error; pub(crate) mod google_groups; mod request; mod response; mod sso; pub use config::*; pub use error::*; pub use openidconnect::core::CoreIdToken; pub use request::*; pub use response::*; pub use sso::*; ================================================ FILE: warpgate-sso/src/request.rs ================================================ use openidconnect::url::Url; use openidconnect::{CsrfToken, Nonce, PkceCodeVerifier, RedirectUrl}; use serde::{Deserialize, Serialize}; use tracing::{debug, error}; use crate::{SsoClient, SsoError, SsoInternalProviderConfig, SsoLoginResponse}; #[derive(Serialize, Deserialize, Debug)] pub struct SsoLoginRequest { pub(crate) auth_url: Url, pub(crate) csrf_token: CsrfToken, pub(crate) nonce: Nonce, pub(crate) redirect_url: RedirectUrl, pub(crate) pkce_verifier: Option, pub(crate) config: SsoInternalProviderConfig, } impl SsoLoginRequest { pub fn auth_url(&self) -> &Url { &self.auth_url } pub fn csrf_token(&self) -> &CsrfToken { &self.csrf_token } pub fn redirect_url(&self) -> &RedirectUrl { &self.redirect_url } pub async fn verify_code(self, code: String) -> Result { let config = self.config; let result = SsoClient::new(config.clone())? .finish_login(self.pkce_verifier, self.redirect_url, &self.nonce, code) .await?; debug!("OIDC claims: {:?}", result.claims); debug!("OIDC userinfo claims: {:?}", result.userinfo_claims); macro_rules! get_claim { ($method:ident) => { result .claims .$method() .or(result.userinfo_claims.as_ref().and_then(|x| x.$method())) }; } // If preferred_username is absent, fall back to `email` let preferred_username = get_claim!(preferred_username) .map(|x| x.as_str()) .map(ToString::to_string) .or_else(|| { get_claim!(email) .map(|x| x.as_str()) .map(ToString::to_string) }); let name = get_claim!(name) .and_then(|x| x.get(None)) .map(|x| x.as_str()) .map(ToString::to_string); let email = get_claim!(email) .map(|x| x.as_str()) .map(ToString::to_string); let email_verified = get_claim!(email_verified); let info_claims = result.userinfo_claims.as_ref(); let (access_groups, admin_groups) = match crate::google_groups::fetch_groups_if_configured(&config, email.as_deref()).await { Ok(Some(google_groups)) => (Some(google_groups.clone()), Some(google_groups)), Ok(None) => ( info_claims.and_then(|x| x.additional_claims().warpgate_roles.clone()), info_claims.and_then(|x| x.additional_claims().warpgate_admin_roles.clone()), ), Err(e) => { error!("Failed to fetch Google groups: {e}"); (None, None) } }; Ok(SsoLoginResponse { preferred_username, name, email, email_verified, access_roles: access_groups, admin_roles: admin_groups, id_token: result.token.clone(), }) } } ================================================ FILE: warpgate-sso/src/response.rs ================================================ use openidconnect::core::CoreIdToken; #[derive(Clone, Debug)] pub struct SsoLoginResponse { pub name: Option, pub email: Option, pub email_verified: Option, pub access_roles: Option>, pub admin_roles: Option>, pub id_token: CoreIdToken, pub preferred_username: Option, } ================================================ FILE: warpgate-sso/src/sso.rs ================================================ use std::borrow::Cow; use std::ops::Deref; use futures::future::OptionFuture; use openidconnect::core::{ CoreAuthenticationFlow, CoreClient, CoreGenderClaim, CoreIdToken, CoreIdTokenClaims, }; use openidconnect::url::Url; use openidconnect::{ reqwest, AccessTokenHash, AdditionalClaims, AuthorizationCode, CsrfToken, DiscoveryError, EndpointMaybeSet, EndpointNotSet, EndpointSet, LogoutRequest, Nonce, OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, PostLogoutRedirectUrl, ProviderMetadataWithLogout, RedirectUrl, RequestTokenError, Scope, TokenResponse, UserInfoClaims, }; use serde::{Deserialize, Serialize}; use tracing::error; use crate::config::SsoInternalProviderConfig; use crate::request::SsoLoginRequest; use crate::SsoError; /// Deserialize a value that may be either a single string or a sequence of strings. /// /// Some OIDC providers (e.g. oidc-mock) return a single claim value /// as a bare string rather than a one-element array. /// This deserializer accepts both forms. fn string_or_vec<'de, D>(deserializer: D) -> Result>, D::Error> where D: serde::Deserializer<'de>, { #[derive(Deserialize)] #[serde(untagged)] enum StringOrVec { String(String), Vec(Vec), } Option::::deserialize(deserializer).map(|opt| { opt.map(|sv| match sv { StringOrVec::String(s) => vec![s], StringOrVec::Vec(v) => v, }) }) } #[derive(Debug, Deserialize, Serialize, Clone)] pub struct WarpgateClaims { #[serde(default, deserialize_with = "string_or_vec")] pub warpgate_roles: Option>, #[serde(default, deserialize_with = "string_or_vec")] pub warpgate_admin_roles: Option>, } impl AdditionalClaims for WarpgateClaims {} pub struct SsoResult { pub token: CoreIdToken, pub claims: CoreIdTokenClaims, pub userinfo_claims: Option>, } pub struct SsoClient { config: SsoInternalProviderConfig, http_client: reqwest::Client, } pub async fn discover_metadata( config: &SsoInternalProviderConfig, http_client: &reqwest::Client, ) -> Result { ProviderMetadataWithLogout::discover_async(config.issuer_url()?, http_client) .await .map_err(|e| { SsoError::Discovery(match e { DiscoveryError::Request(inner) => format!("Request error: {inner:?}"), e => format!("{e}"), }) }) } async fn make_client( config: &SsoInternalProviderConfig, http_client: &reqwest::Client, ) -> Result< CoreClient< EndpointSet, // HasAuthUrl EndpointNotSet, // HasDeviceAuthUrl EndpointNotSet, // HasIntrospectionUrl EndpointNotSet, // HasRevocationUrl EndpointMaybeSet, // HasTokenUrl EndpointMaybeSet, // HasUserInfoUrl >, SsoError, > { let metadata = discover_metadata(config, http_client).await?; let client = CoreClient::from_provider_metadata( metadata, config.client_id().clone(), Some(config.client_secret()?), ) .set_auth_type(config.auth_type()); Ok(client) } impl SsoClient { pub fn new(config: SsoInternalProviderConfig) -> Result { Ok(Self { config, http_client: reqwest::ClientBuilder::new().build()?, }) } pub async fn supports_single_logout(&self) -> Result { let metadata = discover_metadata(&self.config, &self.http_client).await?; Ok(metadata .additional_metadata() .end_session_endpoint .is_some()) } pub async fn start_login(&self, redirect_url: String) -> Result { let redirect_url = RedirectUrl::new(redirect_url)?; let client = make_client(&self.config, &self.http_client).await?; let mut auth_req = client .authorize_url( CoreAuthenticationFlow::AuthorizationCode, CsrfToken::new_random, Nonce::new_random, ) .set_redirect_uri(Cow::Owned(redirect_url.clone())); for (k, v) in self.config.extra_parameters() { auth_req = auth_req.add_extra_param(k, v); } for scope in self.config.scopes() { auth_req = auth_req.add_scope(Scope::new(scope.to_string())); } let pkce_verifier = if self.config.needs_pkce_verifier() { let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); auth_req = auth_req.set_pkce_challenge(pkce_challenge); Some(pkce_verifier) } else { None }; let (auth_url, csrf_token, nonce) = auth_req.url(); Ok(SsoLoginRequest { auth_url, csrf_token, nonce, pkce_verifier, redirect_url, config: self.config.clone(), }) } pub async fn finish_login( &self, pkce_verifier: Option, redirect_url: RedirectUrl, nonce: &Nonce, code: String, ) -> Result { let client = make_client(&self.config, &self.http_client) .await? .set_redirect_uri(redirect_url); let mut req = client.exchange_code(AuthorizationCode::new(code))?; if let Some(verifier) = pkce_verifier { req = req.set_pkce_verifier(verifier); } let token_response = req .request_async(&self.http_client) .await .map_err(|e| match e { RequestTokenError::ServerResponse(response) => { SsoError::Verification(response.error().to_string()) } RequestTokenError::Parse(err, path) => SsoError::Verification(format!( "Parse error: {:?} / {:?}", err, String::from_utf8_lossy(&path) )), e => SsoError::Verification(format!("{e}")), })?; let mut token_verifier = client.id_token_verifier(); if let Some(trusted_audiences) = self.config.additional_trusted_audiences() { token_verifier = token_verifier .set_other_audience_verifier_fn(|aud| trusted_audiences.contains(aud.deref())); } if self.config.trust_unknown_audiences() { token_verifier = token_verifier.set_other_audience_verifier_fn(|_aud| true); } let id_token: &CoreIdToken = token_response.id_token().ok_or(SsoError::NotOidc)?; let claims = id_token.claims(&token_verifier, nonce)?; let user_info_req = client .user_info(token_response.access_token().to_owned(), None) .map_err(|err| { error!("Failed to fetch userinfo: {err:?}"); err }) .ok(); let userinfo_claims: Option> = OptionFuture::from(user_info_req.map(|req| req.request_async(&self.http_client))) .await .and_then(|res| { res.map_err(|err| { error!("Failed to fetch userinfo: {err:?}"); err }) .ok() }); if let Some(expected_access_token_hash) = claims.access_token_hash() { let actual_access_token_hash = AccessTokenHash::from_token( token_response.access_token(), id_token.signing_alg()?, id_token.signing_key(&token_verifier)?, )?; if actual_access_token_hash != *expected_access_token_hash { return Err(SsoError::Mitm); } } Ok(SsoResult { token: id_token.clone(), userinfo_claims, claims: claims.clone(), }) } pub async fn logout(&self, token: CoreIdToken, redirect_url: Url) -> Result { let metadata = discover_metadata(&self.config, &self.http_client).await?; let Some(ref url) = metadata.additional_metadata().end_session_endpoint else { return Err(SsoError::LogoutNotSupported); }; let mut req: LogoutRequest = url.clone().into(); req = req.set_id_token_hint(&token); req = req.set_client_id(self.config.client_id().clone()); req = req.set_post_logout_redirect_uri(PostLogoutRedirectUrl::from_url(redirect_url)); Ok(req.http_get_url()) } } ================================================ FILE: warpgate-tls/Cargo.toml ================================================ [package] edition = "2021" license = "Apache-2.0" name = "warpgate-tls" version = "0.22.0" [dependencies] once_cell = { version = "1.17", default-features = false } poem.workspace = true poem-openapi.workspace = true rustls-native-certs = { version = "0.8", default-features = false } rustls-pki-types.workspace = true rustls.workspace = true sea-orm.workspace = true serde_json.workspace = true serde.workspace = true thiserror.workspace = true tokio-rustls.workspace = true tokio.workspace = true tracing.workspace = true webpki = { version = "0.22", default-features = false } x509-parser = "0.17.0" ================================================ FILE: warpgate-tls/src/cert.rs ================================================ use std::net::{Ipv4Addr, Ipv6Addr}; use std::path::{Path, PathBuf}; use std::sync::Arc; use poem::listener::RustlsCertificate; use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use rustls::server::ResolvesServerCert; use rustls::sign::{CertifiedKey, SigningKey}; use rustls_pki_types::pem::PemObject; use tokio::fs::File; use tokio::io::AsyncReadExt; use x509_parser::prelude::{FromDer, GeneralName, ParsedExtension, X509Certificate}; use crate::RustlsSetupError; #[derive(Debug, Clone)] pub struct TlsCertificateBundle { bytes: Vec, certificates: Vec>, } #[derive(Debug, Clone)] pub struct TlsPrivateKey { bytes: Vec, key: Arc, } impl TlsPrivateKey { pub fn key(&self) -> &Arc { &self.key } } #[derive(Debug, Clone)] pub struct TlsCertificateAndPrivateKey { pub certificate: TlsCertificateBundle, pub private_key: TlsPrivateKey, } impl TlsCertificateBundle { pub fn bytes(&self) -> &[u8] { &self.bytes } pub fn certificates(&self) -> &[CertificateDer<'static>] { &self.certificates } pub async fn from_file>(path: P) -> Result { let mut file = File::open(path).await?; let mut bytes = Vec::new(); file.read_to_end(&mut bytes).await?; Self::from_bytes(bytes) } pub fn from_bytes(bytes: Vec) -> Result { let certificates = CertificateDer::pem_slice_iter(&bytes[..]) .collect::>, _>>()?; if certificates.is_empty() { return Err(RustlsSetupError::NoCertificates); } Ok(Self { bytes, certificates, }) } pub fn sni_names(&self) -> Result, RustlsSetupError> { // Parse leaf certificate let Some(cert_der) = self.certificates.first() else { return Ok(Vec::new()); }; let (_, cert) = X509Certificate::from_der(cert_der).map_err(|e| RustlsSetupError::X509(e.into()))?; let mut names = Vec::new(); if let Some(san_ext) = cert .extensions() .iter() .find(|ext| ext.oid == x509_parser::oid_registry::OID_X509_EXT_SUBJECT_ALT_NAME) { let san = san_ext.parsed_extension(); if let ParsedExtension::SubjectAlternativeName(san) = san { for name in &san.general_names { match name { GeneralName::DNSName(dns_name) => { names.push(dns_name.to_string()); } GeneralName::IPAddress(ip_bytes) => { if ip_bytes.len() == 4 { #[allow(clippy::unwrap_used)] // length checked names.push( Ipv4Addr::from(<[u8; 4]>::try_from(*ip_bytes).unwrap()) .to_string(), ); } else if ip_bytes.len() == 16 { #[allow(clippy::unwrap_used)] // length checked names.push( Ipv6Addr::from(<[u8; 16]>::try_from(*ip_bytes).unwrap()) .to_string(), ); } } _ => {} } } } } if let Some(subject) = cert.subject().iter_common_name().next() { if let Ok(cn) = subject.as_str() { names.push(cn.to_string()); } } // Remove duplicates while preserving order let mut unique_names = Vec::new(); for name in names { if !unique_names.contains(&name) { unique_names.push(name); } } Ok(unique_names) } } impl TlsPrivateKey { pub async fn from_file>(path: P) -> Result { let mut file = File::open(path).await?; let mut bytes = Vec::new(); file.read_to_end(&mut bytes).await?; Self::from_bytes(bytes) } pub fn from_bytes(bytes: Vec) -> Result { let key = PrivateKeyDer::from_pem_slice(bytes.as_slice())?; let key = rustls::crypto::aws_lc_rs::sign::any_supported_type(&key)?; Ok(Self { bytes, key }) } } impl From for Vec { fn from(val: TlsCertificateBundle) -> Self { val.bytes } } impl From for Vec { fn from(val: TlsPrivateKey) -> Self { val.bytes } } impl From for RustlsCertificate { fn from(val: TlsCertificateAndPrivateKey) -> Self { RustlsCertificate::new() .cert(val.certificate) .key(val.private_key) } } impl From for CertifiedKey { fn from(val: TlsCertificateAndPrivateKey) -> Self { let cert = val.certificate; let key = val.private_key; CertifiedKey { cert: cert.certificates, key: key.key, ocsp: None, } } } #[derive(Debug, Clone)] pub struct SingleCertResolver(Arc); impl SingleCertResolver { pub fn new(inner: TlsCertificateAndPrivateKey) -> Self { Self(Arc::new(inner.into())) } } impl ResolvesServerCert for SingleCertResolver { fn resolve( &self, _client_hello: rustls::server::ClientHello<'_>, ) -> Option> { Some(self.0.clone()) } } pub trait IntoTlsCertificateRelativePaths { fn certificate_path(&self) -> PathBuf; fn key_path(&self) -> PathBuf; } ================================================ FILE: warpgate-tls/src/error.rs ================================================ use rustls::server::VerifierBuilderError; use x509_parser::error::X509Error; #[derive(thiserror::Error, Debug)] pub enum RustlsSetupError { #[error("rustls: {0}")] Rustls(#[from] rustls::Error), #[error("rustls: {0}")] RustlsPem(#[from] rustls_pki_types::pem::Error), #[error("verifier setup: {0}")] VerifierBuilder(#[from] VerifierBuilderError), #[error("no certificates found in certificate file")] NoCertificates, #[error("no private keys found in key file")] NoKeys, #[error("I/O: {0}")] Io(#[from] std::io::Error), #[error("PKI: {0}")] Pki(webpki::Error), #[error("parsing certificate: {0}")] X509(#[from] X509Error), } ================================================ FILE: warpgate-tls/src/lib.rs ================================================ mod cert; mod error; mod maybe_tls_stream; mod mode; mod rustls_helpers; mod rustls_root_certs; pub use cert::*; pub use error::*; pub use maybe_tls_stream::{MaybeTlsStream, MaybeTlsStreamError, UpgradableStream}; pub use mode::TlsMode; pub use rustls_helpers::{configure_tls_connector, ResolveServerCert}; pub use rustls_root_certs::ROOT_CERT_STORE; ================================================ FILE: warpgate-tls/src/maybe_tls_stream.rs ================================================ use std::future::Future; use std::pin::Pin; use std::sync::Arc; use std::task::Poll; use rustls::pki_types::ServerName; use rustls::{ClientConfig, ServerConfig}; use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; #[derive(thiserror::Error, Debug)] pub enum MaybeTlsStreamError { #[error("stream is already upgraded")] AlreadyUpgraded, #[error("I/O: {0}")] Io(#[from] std::io::Error), } pub trait UpgradableStream where Self: Sized, T: AsyncRead + AsyncWrite + Unpin, { type UpgradeConfig; fn upgrade( self, config: Self::UpgradeConfig, ) -> impl Future> + Send; } pub enum MaybeTlsStream where S: AsyncRead + AsyncWrite + Unpin + UpgradableStream, TS: AsyncRead + AsyncWrite + Unpin, { Tls(TS), Raw(S), Upgrading, } impl MaybeTlsStream where S: AsyncRead + AsyncWrite + Unpin + UpgradableStream, TS: AsyncRead + AsyncWrite + Unpin, { pub fn new(stream: S) -> Self { Self::Raw(stream) } } impl MaybeTlsStream where S: AsyncRead + AsyncWrite + Unpin + UpgradableStream, TS: AsyncRead + AsyncWrite + Unpin, { pub async fn upgrade( mut self, tls_config: S::UpgradeConfig, ) -> Result { if let Self::Raw(stream) = std::mem::replace(&mut self, Self::Upgrading) { let stream = stream.upgrade(tls_config).await?; Ok(MaybeTlsStream::Tls(stream)) } else { Err(MaybeTlsStreamError::AlreadyUpgraded) } } } impl AsyncRead for MaybeTlsStream where S: AsyncRead + AsyncWrite + Unpin + UpgradableStream, TS: AsyncRead + AsyncWrite + Unpin, { fn poll_read( self: Pin<&mut Self>, cx: &mut std::task::Context<'_>, buf: &mut ReadBuf<'_>, ) -> Poll> { match self.get_mut() { MaybeTlsStream::Tls(tls) => Pin::new(tls).poll_read(cx, buf), MaybeTlsStream::Raw(stream) => Pin::new(stream).poll_read(cx, buf), _ => unreachable!(), } } } impl AsyncWrite for MaybeTlsStream where S: AsyncRead + AsyncWrite + Unpin + UpgradableStream, TS: AsyncRead + AsyncWrite + Unpin, { fn poll_write( self: Pin<&mut Self>, cx: &mut std::task::Context<'_>, buf: &[u8], ) -> std::task::Poll> { match self.get_mut() { MaybeTlsStream::Tls(tls) => Pin::new(tls).poll_write(cx, buf), MaybeTlsStream::Raw(stream) => Pin::new(stream).poll_write(cx, buf), _ => unreachable!(), } } fn poll_flush( self: Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { match self.get_mut() { MaybeTlsStream::Tls(tls) => Pin::new(tls).poll_flush(cx), MaybeTlsStream::Raw(stream) => Pin::new(stream).poll_flush(cx), _ => unreachable!(), } } fn poll_shutdown( self: Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { match self.get_mut() { MaybeTlsStream::Tls(tls) => Pin::new(tls).poll_shutdown(cx), MaybeTlsStream::Raw(stream) => Pin::new(stream).poll_shutdown(cx), _ => unreachable!(), } } } impl UpgradableStream> for S where S: AsyncRead + AsyncWrite + Unpin + Send, { type UpgradeConfig = (ServerName<'static>, Arc); async fn upgrade( self, config: Self::UpgradeConfig, ) -> Result, MaybeTlsStreamError> { let (domain, tls_config) = config; let connector = tokio_rustls::TlsConnector::from(tls_config); Ok(connector.connect(domain, self).await?) } } impl UpgradableStream> for S where S: AsyncRead + AsyncWrite + Unpin + Send, { type UpgradeConfig = Arc; async fn upgrade( self, tls_config: Self::UpgradeConfig, ) -> Result, MaybeTlsStreamError> { let acceptor = tokio_rustls::TlsAcceptor::from(tls_config); Ok(acceptor.accept(self).await?) } } ================================================ FILE: warpgate-tls/src/mode.rs ================================================ use poem_openapi::Enum; use serde::{Deserialize, Serialize}; #[derive(Debug, Deserialize, Serialize, Clone, Copy, Enum, PartialEq, Eq, Default)] pub enum TlsMode { #[serde(rename = "disabled")] Disabled, #[serde(rename = "preferred")] #[default] Preferred, #[serde(rename = "required")] Required, } impl From<&str> for TlsMode { fn from(s: &str) -> Self { match s { "Disabled" => TlsMode::Disabled, "Preferred" => TlsMode::Preferred, "Required" => TlsMode::Required, _ => TlsMode::Preferred, } } } impl From for String { fn from(mode: TlsMode) -> Self { match mode { TlsMode::Disabled => "Disabled".to_string(), TlsMode::Preferred => "Preferred".to_string(), TlsMode::Required => "Required".to_string(), } } } ================================================ FILE: warpgate-tls/src/rustls_helpers.rs ================================================ use std::io::Cursor; use std::sync::Arc; use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; use rustls::client::WebPkiServerVerifier; use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; use rustls::server::{ClientHello, ResolvesServerCert}; use rustls::sign::CertifiedKey; use rustls::{CertificateError, ClientConfig, Error as TlsError, SignatureScheme}; use rustls_pki_types::pem::PemObject; use super::{RustlsSetupError, ROOT_CERT_STORE}; #[derive(Debug)] pub struct ResolveServerCert(pub Arc); impl ResolvesServerCert for ResolveServerCert { fn resolve(&self, _: ClientHello) -> Option> { Some(self.0.clone()) } } pub async fn configure_tls_connector( accept_invalid_certs: bool, accept_invalid_hostnames: bool, root_cert: Option<&[u8]>, ) -> Result { let config = ClientConfig::builder_with_provider(Arc::new( rustls::crypto::aws_lc_rs::default_provider(), )) .with_safe_default_protocol_versions()?; let config = if accept_invalid_certs { config .dangerous() .with_custom_certificate_verifier(Arc::new(DummyTlsVerifier)) .with_no_client_auth() } else { let mut cert_store = ROOT_CERT_STORE.clone(); if let Some(data) = root_cert { let mut cursor = Cursor::new(data); for cert in CertificateDer::pem_reader_iter(&mut cursor) { cert_store.add(cert?)?; } } if accept_invalid_hostnames { let verifier = WebPkiServerVerifier::builder(Arc::new(cert_store)).build()?; config .dangerous() .with_custom_certificate_verifier(Arc::new(NoHostnameTlsVerifier { verifier })) .with_no_client_auth() } else { config .with_root_certificates(cert_store) .with_no_client_auth() } }; Ok(config) } #[derive(Debug)] pub struct DummyTlsVerifier; impl ServerCertVerifier for DummyTlsVerifier { fn verify_server_cert( &self, _end_entity: &CertificateDer<'_>, _intermediates: &[CertificateDer<'_>], _server_name: &ServerName<'_>, _ocsp_response: &[u8], _now: UnixTime, ) -> Result { Ok(ServerCertVerified::assertion()) } fn verify_tls12_signature( &self, _message: &[u8], _cert: &CertificateDer<'_>, _dss: &rustls::DigitallySignedStruct, ) -> Result { Ok(HandshakeSignatureValid::assertion()) } fn verify_tls13_signature( &self, _message: &[u8], _cert: &CertificateDer<'_>, _dss: &rustls::DigitallySignedStruct, ) -> Result { Ok(HandshakeSignatureValid::assertion()) } fn supported_verify_schemes(&self) -> Vec { vec![ SignatureScheme::RSA_PKCS1_SHA1, SignatureScheme::ECDSA_SHA1_Legacy, SignatureScheme::RSA_PKCS1_SHA256, SignatureScheme::ECDSA_NISTP256_SHA256, SignatureScheme::RSA_PKCS1_SHA384, SignatureScheme::ECDSA_NISTP384_SHA384, SignatureScheme::RSA_PKCS1_SHA512, SignatureScheme::ECDSA_NISTP521_SHA512, SignatureScheme::RSA_PSS_SHA256, SignatureScheme::RSA_PSS_SHA384, SignatureScheme::RSA_PSS_SHA512, SignatureScheme::ED25519, SignatureScheme::ED448, ] } } #[derive(Debug)] pub struct NoHostnameTlsVerifier { verifier: Arc, } impl ServerCertVerifier for NoHostnameTlsVerifier { fn verify_server_cert( &self, end_entity: &CertificateDer<'_>, intermediates: &[CertificateDer<'_>], server_name: &ServerName<'_>, ocsp_response: &[u8], now: UnixTime, ) -> Result { match self.verifier.verify_server_cert( end_entity, intermediates, server_name, ocsp_response, now, ) { Err(TlsError::InvalidCertificate(CertificateError::NotValidForName)) => { Ok(ServerCertVerified::assertion()) } res => res, } } fn verify_tls12_signature( &self, message: &[u8], cert: &CertificateDer<'_>, dss: &rustls::DigitallySignedStruct, ) -> Result { self.verifier.verify_tls12_signature(message, cert, dss) } fn verify_tls13_signature( &self, message: &[u8], cert: &CertificateDer<'_>, dss: &rustls::DigitallySignedStruct, ) -> Result { self.verifier.verify_tls13_signature(message, cert, dss) } fn supported_verify_schemes(&self) -> Vec { self.verifier.supported_verify_schemes() } } ================================================ FILE: warpgate-tls/src/rustls_root_certs.rs ================================================ use once_cell::sync::Lazy; use rustls::RootCertStore; #[allow(clippy::expect_used)] pub static ROOT_CERT_STORE: Lazy = Lazy::new(|| { let mut roots = RootCertStore::empty(); for cert in rustls_native_certs::load_native_certs().expect("could not load root TLS certificates") { roots.add(cert).expect("could not add root TLS certificate"); } roots }); ================================================ FILE: warpgate-web/.editorconfig ================================================ root = true [*] indent_style = space indent_size = 4 charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true end_of_line = lf ================================================ FILE: warpgate-web/.gitignore ================================================ # 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 ================================================ FILE: warpgate-web/Cargo.toml ================================================ [package] edition = "2021" license = "Apache-2.0" name = "warpgate-web" version = "0.22.0" [dependencies] serde.workspace = true rust-embed = { version = "8.3", default-features = false } serde_json.workspace = true thiserror.workspace = true ================================================ FILE: warpgate-web/eslint.config.mjs ================================================ // eslint.config.cjs import globals from "globals"; import eslintPluginSvelte from 'eslint-plugin-svelte'; import js from '@eslint/js'; import svelteParser from 'svelte-eslint-parser'; import tsEslint from 'typescript-eslint'; import tsParser from '@typescript-eslint/parser'; import stylistic from '@stylistic/eslint-plugin' export default [ js.configs.recommended, ...tsEslint.configs.strict, ...eslintPluginSvelte.configs['flat/recommended'], { ignores: ["**/svelte.config.js", "**/vite.config.ts", "src/*/lib/api-client/**/*"], }, { plugins: { '@stylistic': stylistic, }, languageOptions: { parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname, parser: tsParser, extraFileExtensions: [".svelte"], }, globals: { ...globals.browser, T: false, }, }, rules: { "@stylistic/semi": ["error", "never"], "@stylistic/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/explicit-module-boundary-types": "error", // "@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, functions: false, }], "no-duplicate-imports": "error", "array-bracket-spacing": ["error", "never"], "block-scoped-var": "error", "brace-style": "off", "@stylistic/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", "@stylistic/object-curly-spacing": ["error", "always"], "quote-props": ["warn", "as-needed", { keywords: true, numbers: true, }], quotes: "off", "@stylistic/quotes": ["error", "single", { allowTemplateLiterals: 'always', }], "@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", "@stylistic/lines-between-class-members": ["error", "always", { 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", }], "@stylistic/comma-dangle": ["error", { arrays: "always-multiline", objects: "always-multiline", imports: "always-multiline", exports: "always-multiline", functions: "only-multiline", }], "@typescript-eslint/use-unknown-in-catch-callback-variable": "off", }, }, { files: ['**/*.svelte'], languageOptions: { parser: svelteParser, parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname, parser: tsParser, }, }, rules: { 'svelte/no-target-blank': 'error', 'svelte/no-at-debug-tags': 'error', 'svelte/no-reactive-functions': 'error', 'svelte/no-reactive-literals': 'error', }, }, ]; ================================================ FILE: warpgate-web/openapitools.json ================================================ { "$schema": "node_modules/@openapitools/openapi-generator-cli/config.schema.json", "spaces": 2, "generator-cli": { "version": "7.7.0" } } ================================================ FILE: warpgate-web/package.json ================================================ { "name": "warpgate-admin", "private": true, "version": "0.0.0", "type": "module", "scripts": { "build": "vite build", "devbuild": "vite build --mode development --minify false", "watch": "vite build -w --mode development --minify false", "check": "svelte-check --compiler-warnings 'a11y-no-noninteractive-element-interactions:ignore,a11y-click-events-have-key-events:ignore,a11y-no-static-element-interactions:ignore' --tsconfig ./tsconfig.json", "lint": "eslint src && svelte-check", "postinstall": "npm run openapi:client:gateway && npm 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@5 && npm i && npx 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@5 && npm i && npx tsc --target esnext --module esnext && rm -rf src tsconfig.json", "openapi:tests-sdk": "openapi-generator-cli generate -g python -i src/admin/lib/openapi-schema.json -o ../tests/api_sdk", "openapi": "npm run openapi:schema:admin && npm run openapi:schema:gateway && npm run openapi:client:admin && npm run openapi:client:gateway" }, "devDependencies": { "@fontsource/poppins": "^5.2.7", "@fontsource/work-sans": "^5.2.8", "@fortawesome/free-brands-svg-icons": "^7.2.0", "@fortawesome/free-regular-svg-icons": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0", "@openapitools/openapi-generator-cli": "^2.30.2", "@otplib/plugin-base32-enc-dec": "^12.0.1", "@otplib/plugin-crypto-js": "^12.0.1", "@otplib/preset-browser": "^12.0.1", "@stylistic/eslint-plugin": "^5.10.0", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltestrap/sveltestrap": "^6.2.7", "@tsconfig/svelte": "^5.0.8", "@types/qrcode": "^1.5.6", "@types/ua-parser-js": "^0.7.36", "@xterm/addon-serialize": "^0.14", "@xterm/xterm": "^5.5", "bootstrap": "^5.3.8", "copy-text-to-clipboard": "^3.2.2", "date-fns": "^4.1.0", "eslint": "^9", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.32.0", "eslint-plugin-node": "^11.1.0", "eslint-plugin-promise": "^6", "eslint-plugin-svelte": "^3.15.2", "format-duration": "^3.0.2", "otpauth": "^9.5.0", "qrcode": "^1.5.4", "rxjs": "^7.8.2", "sass": "1.78", "svelte": "^5.54.0", "svelte-check": "^4.4.5", "svelte-fa": "^4.0.4", "svelte-intersection-observer": "^1.1.1", "svelte-observable": "^0.4.0", "svelte-preprocess": "^6.0.3", "svelte-spa-router": "^4.0.1", "thenby": "^1.3.4", "tslib": "^2.8.0", "typescript": "^5.9.3", "typescript-eslint": "^8.57.1", "ua-parser-js": "^2.0.9", "vite": "^7.3.1", "vite-plugin-checker": "^0.12.0", "vite-tsconfig-paths": "^6.1.1" }, "overrides": { "svelte-observable": { "svelte": "^5" }, "@eslint-community/eslint-utils": { "eslint": "^9" } }, "dependencies": { "natural-orderby": "^5.0.0" } } ================================================ FILE: warpgate-web/src/admin/App.svelte ================================================
{#if $serverInfo?.username} Sessions Config Log {/if}
{$serverInfo?.version}
================================================ FILE: warpgate-web/src/admin/AuthPolicyEditor.svelte ================================================
{#if !isAny} {#each [...validCredentials] as type (type)} {#if possibleCredentials.has(type)} toggle(type)} /> {/if} {/each} {/if}
{#each activeTips as tip (tip)} {tip} {/each} ================================================ FILE: warpgate-web/src/admin/CertificateCredentialModal.svelte ================================================ {#if generatedCertificatePem}

Certificate has been issued

You must download the private key and the certificate now - you won't be able to access them later. {:else}
A private key will be generated locally in your browser.
You'll need to save it after the certificate is issued.
{/if}
{#if !generatedCertificatePem} Issue certificate {:else} {/if} {#if privateKeyPem} {/if} {#if generatedKubeConfig} {/if}
================================================ FILE: warpgate-web/src/admin/CreateOtpModal.svelte ================================================ field?.focus()}>
{ _save() e.preventDefault() }}> OTP QR code
================================================ FILE: warpgate-web/src/admin/CreatePasswordModal.svelte ================================================ field?.focus()}>
{ _save() e.preventDefault() }}>
================================================ FILE: warpgate-web/src/admin/CredentialEditor.svelte ================================================

Credentials

{#if $adminPermissions.usersEdit} Public key credentials will be loaded from LDAP {/if}
{#if credentials.length === 0} {/if}
{#each credentials as credential (credential.id)}
{#if credential.kind === CredentialKind.Password } Password {/if} {#if credential.kind === 'PublicKey'}
{credential.label}
{abbreviatePublicKey(credential.opensshPublicKey)}
{/if} {#if credential.kind === CredentialKind.Certificate}
{credential.label}
SHA-256: {credential.fingerprint}
{/if} {#if credential.kind === 'Totp'} One-time password {/if} {#if credential.kind === CredentialKind.Sso} Single sign-on {credential.email} {#if credential.provider} ({credential.provider}){/if} {/if} {#if credential.kind === CredentialKind.PublicKey || credential.kind === CredentialKind.Sso} {/if}
{/each}

Auth policy

{#each policyProtocols as protocol (protocol)} {@const effectiveCredentials = getEffectivePossibleCredentials(protocol.id)}
{protocol.name}
{#if effectiveCredentials.size > 0} {:else} No authentication methods available for this protocol {/if}
{/each}
{#if creatingPassword} {/if} {#if creatingOtp} {/if} {#if editingSsoCredential} {/if} {#if editingPublicKeyCredential} {/if} {#if editingCertificateCredential} { editingCertificateCredential = false }} /> {/if} ================================================ FILE: warpgate-web/src/admin/Home.svelte ================================================ {#if $serverInfo?.setupState} {/if} {#if activeSessionCount !== undefined}
{#if activeSessionCount }

active sessions: {activeSessionCount}

{#if $adminPermissions.sessionsTerminate} Close all {/if}
{:else}

no active sessions

{/if}
{/if} {#snippet header()}
{/snippet} {#snippet item(session)}
{#if !session.ended} {/if}
{session.protocol}
{describeSession(session)}
{#if session.ended } {formatDistance(new Date(session.started), new Date(session.ended))} {/if}
{/snippet}
================================================ FILE: warpgate-web/src/admin/KubernetesRecording.svelte ================================================ {#each sortedItems as item (item)}
{#if item.responseStatus} {item.responseStatus} {/if} {#if item.requestMethod === 'GET'} {item.requestMethod} {:else} {item.requestMethod} {/if} {item.requestPath}
{item.timestamp.toLocaleString()}
{#if item.requestBody}
Request body ({item.requestBody.kind})
{JSON.stringify(item.requestBody, undefined, 2)}
{/if} {#if item.responseBody}
Response body ({item.responseBody.kind}) {#if item.responseBody.kind === 'Table'} {#each item.responseBody.columnDefinitions as colDef (colDef)} {/each} {#each item.responseBody.rows as row (row)} {#each row.cells as cell} {/each} {/each}
{colDef.name}
{cell}
{:else if item.responseBody.kind?.endsWith('List') } {#if item.responseBody.items?.length === 0}
No items
{:else} {#each item.responseBody.items as row (row)} {/each}
Name Namespace
{row.metadata.name} {row.metadata.namespace}
Full entry
{JSON.stringify(row, undefined, 2)}
{/if} {:else}
{JSON.stringify(item.responseBody, undefined, 2)}
{/if}
{/if}
{/each} ================================================ FILE: warpgate-web/src/admin/Log.svelte ================================================

log

================================================ FILE: warpgate-web/src/admin/LogViewer.svelte ================================================ {#if error} {error} {/if} search()} /> {#if visibleItems}
{#each visibleItems as item (item.id)} {#if !filters?.sessionId} {/if} {/each} {#if !endReached} {#if !loading} {/if} {:else} {#if !filters?.sessionId} {/if} {/if}
{stringifyDate(item.timestamp)} {#if item.username} {item.username} {/if} {#if item.sessionId} {item.sessionId} {/if} {item.text} {#each Object.entries(item.values ?? {}) as pair (pair[0])} {pair[0]}: {pair[1]} {/each}
{ if (!loading && event.detail.isIntersecting) { loadOlder() } }}>
End of the log
{/if} ================================================ FILE: warpgate-web/src/admin/PublicKeyCredentialModal.svelte ================================================ { if (instance) { label = instance.label opensshPublicKey = instance.opensshPublicKey } field?.focus() }}>
{ _save() e.preventDefault() }}>
================================================ FILE: warpgate-web/src/admin/Recording.svelte ================================================

session recording

{#if !recording && !error} {/if} {#if error} {error} {/if} {#if recording?.kind === RecordingKind.Traffic} Download tcpdump file {/if} {#if recording?.kind === RecordingKind.Terminal} {/if} {#if recording?.kind === RecordingKind.Kubernetes} {#snippet children(items)} {/snippet} {/if} ================================================ FILE: warpgate-web/src/admin/RelativeDate.svelte ================================================ {timeAgo(date)} ================================================ FILE: warpgate-web/src/admin/Session.svelte ================================================ {#if !session && !error} {/if} {#if error} {error} {/if} {#if session}

session

Authenticated user Selected target {#if session.username} {session.username} {:else} Logging in {/if} {#if session.target} {getTargetDescription()} {/if} {#if session.ended} {formatDistance(new Date(session.started), new Date(session.ended))} long, {:else} {formatDistanceToNow(new Date(session.started))} {/if}
{#if !session.ended && PROTOCOL_PROPERTIES[session.protocol]?.sessionsCanBeClosed}
Close now
{/if}
{#if recordings?.length }

Recordings

{/if}

Log

{/if} ================================================ FILE: warpgate-web/src/admin/SsoCredentialModal.svelte ================================================ { if (instance) { provider = instance.provider ?? null email = instance.email } }}>
{ _save() e.preventDefault() }}> {#snippet children(providers)} {#if !providers.length} You don't have any SSO providers configured. Add them to your config file first. {/if} {#each providers as provider (provider.name)} {/each} {/snippet}
================================================ FILE: warpgate-web/src/admin/TlsConfiguration.svelte ================================================
{#if value.mode !== TlsMode.Disabled}
{/if}
================================================ FILE: warpgate-web/src/admin/config/AccessRole.svelte ================================================

{role!.name}

role
{#if error} {error} {/if}
Update Remove

Assigned users

{#snippet item(user)}
{user.username} {#if user.description} {user.description} {/if}
{/snippet} {#snippet empty()} This role has no users assigned to it {/snippet}

Assigned targets

{#snippet item(target)}
{target.name} {#if target.description} {target.description} {/if}
{/snippet} {#snippet empty()} This role has no targets assigned to it {/snippet}
================================================ FILE: warpgate-web/src/admin/config/AccessRoles.svelte ================================================ ================================================ FILE: warpgate-web/src/admin/config/AdminRole.svelte ================================================

{role!.name}

admin role

Permissions

{#each permGroups as { category, perms }}
{category}
{#each perms as { key, label } (key)} {/each}
{/each}
{#if error} {error} {/if}
Update Remove

Assigned users

{#snippet item(user)}
{user.username} {#if user.description} {user.description} {/if}
{/snippet} {#snippet empty()} This admin role has no users assigned to it {/snippet}
================================================ FILE: warpgate-web/src/admin/config/AdminRolePermissionsBadge.svelte ================================================ {permissionCount(role)} {permissionCount(role) === 1 ? 'permission' : 'permissions'}
{#each permissionLists(role) as [category, perms] ([category, perms])}
{category}: {perms}
{/each}
================================================ FILE: warpgate-web/src/admin/config/AdminRoles.svelte ================================================

admin roles

permissions for administrators
Create
{#snippet item(role)}
{role.name} {#if role.description} {role.description} {/if}
{/snippet} {#snippet empty()} No admin roles defined {/snippet}
================================================ FILE: warpgate-web/src/admin/config/Config.svelte ================================================ {#snippet navItems()} {/snippet}
{ sidebarMode = e.detail.route !== '' }} />
{@render navItems()}
================================================ FILE: warpgate-web/src/admin/config/CreateAdminRole.svelte ================================================

create admin role

{#if error} {error} {/if}
Create
================================================ FILE: warpgate-web/src/admin/config/CreateRole.svelte ================================================
{#if error} {error} {/if}

add a role

Create role
================================================ FILE: warpgate-web/src/admin/config/CreateTicket.svelte ================================================
{#if error} {error} {/if} {#if result}

ticket created

The secret is only shown once - you won't be able to see it again. {#if selectedTarget && selectedUser} {/if} Done {:else}

create an access ticket

{#if users} {/if} {#if targets} {/if} Create ticket
{/if}
================================================ FILE: warpgate-web/src/admin/config/CreateUser.svelte ================================================
{#if error} {error} {/if}

add a user

{#if !$adminPermissions.usersCreate} You do not have permission to create users. {/if}
Create user
================================================ FILE: warpgate-web/src/admin/config/Parameters.svelte ================================================

global parameters

{#if parameters}

Credentials

Traffic

SSH

Controls which authentication methods are offered to SSH clients. Disabling password authentication can help prevent brute-force attacks. {#if hasSsoProviders}

Login

When enabled, the username and password fields are hidden behind a link on the login page, with the focus on the SSO buttons. {/if} {/if}
================================================ FILE: warpgate-web/src/admin/config/SSHKeys.svelte ================================================

SSH

{#if error} {error} {/if} {#if ownKeys}

Warpgate's own SSH keys

Add these keys to the targets' authorized_keys files
{#each ownKeys as key (key)}
{key.kind} {key.publicKeyBase64}
{/each}
{/if}
{#if knownHosts} {#if knownHosts.length }

Known hosts: {knownHosts.length}

{:else}

No known hosts

{/if}
{#each knownHosts as host (host.id)}
{host.host}:{host.port}
{host.keyType} {host.keyBase64}
{/each}
{/if} ================================================ FILE: warpgate-web/src/admin/config/Tickets.svelte ================================================
{#if error} {error} {/if} {#if tickets}
{#if tickets.length }

access tickets: {tickets.length}

{:else}

access tickets

{/if} Create a ticket
{#if tickets.length}
{#each tickets as ticket (ticket.id)}
Access to {ticket.target} as {ticket.username} {#if ticket.description} {ticket.description} {/if}
{#if ticket.expiry} new Date() ? faCalendarCheck : faCalendarXmark} fw /> Until {ticket.expiry?.toLocaleString()} {/if} {#if ticket.usesLeft != null} {#if ticket.usesLeft > 0} Uses left: {ticket.usesLeft} {/if} {#if ticket.usesLeft === 0} Used up {/if} {/if}
{/each}
{:else} {/if} {/if}
================================================ FILE: warpgate-web/src/admin/config/User.svelte ================================================
{#if user}

{user.username}

User
{#if $serverInfo?.hasLdap} {#if user.ldapServerId} {/if} LDAP {#if user.ldapServerId} Unlink from LDAP {:else} Auto-link to LDAP {/if} {/if}
{#if $adminPermissions.usersEdit} {/if}

User roles

{#each allRoles as role (role.id)} {/each}

Admin roles

{#each allAdminRoles as role (role.id)} {/each}

Traffic

{/if}
{#if error} {error} {/if}
Update Remove
================================================ FILE: warpgate-web/src/admin/config/Users.svelte ================================================

users

Add a user {#if ldapServers.length > 0} Add from LDAP {#each ldapServers as server (server.id)} { push(`/config/ldap-servers/${server.id}/users`) }} disabled={!$adminPermissions.usersCreate} > {server.name} {/each} {/if}
{#snippet item(user)}
{user.username} {#if user.description} {user.description} {/if}
{#if user.ldapServerId} LDAP {/if}
{/snippet}
================================================ FILE: warpgate-web/src/admin/config/ldap/CreateLdapServer.svelte ================================================
{#if error} {error} {/if}

add an LDAP server

{e.preventDefault(); create()}}> {#if testResult} {/if}
Test connection Create
================================================ FILE: warpgate-web/src/admin/config/ldap/LdapConnectionFields.svelte ================================================
================================================ FILE: warpgate-web/src/admin/config/ldap/LdapServer.svelte ================================================

{name}

LDAP server
{ e.preventDefault(); save() }}> {#if baseDns.length > 0}
    {#each baseDns as dn (dn)}
  • {dn}
  • {/each}
{/if}
Automatically link SSO users to their LDAP accounts when they log in
{#if testResult} {/if} {#if error} {/if}
Test Connection Import users
Save Remove
================================================ FILE: warpgate-web/src/admin/config/ldap/LdapServers.svelte ================================================

LDAP Servers

Add LDAP Server
Currently, LDAP can only be used to import users and their SSH keys. Warpgate will automatically sync the public keys of Warpgate users that are linked to LDAP users. {#snippet empty()} {/snippet} {#snippet item(server)} {server.name} {#if server.description} {server.description} {/if} {/snippet}
================================================ FILE: warpgate-web/src/admin/config/ldap/LdapUserBrowser.svelte ================================================ {#if error} {error} {/if} {#if success} {success} {/if} {#if server}

{server.name}

{#if users.length === 0}
Load Users from LDAP
{:else}
{filteredUsers.length} users {searchTerm ? `(filtered from ${users.length})` : ''}
Import {selectedUserDns.length} selected
{#each filteredUsers as user (user.dn)}
{user.username} {#if user.displayName && user.displayName !== user.username} ({user.displayName}) {/if}
DN: {user.dn}
{/each}
{/if}
{/if}
================================================ FILE: warpgate-web/src/admin/config/ldap/common.ts ================================================ import { api, TlsMode, type TestLdapServerRequest, type TestLdapServerResponse } from 'admin/lib/api' export async function testLdapConnection(options: TestLdapServerRequest): Promise { const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('Connection test timed out after 10 seconds')), 10000) }) const testPromise = api.testLdapServerConnection({ testLdapServerRequest: options, }) return (await Promise.race([testPromise, timeoutPromise])) as TestLdapServerResponse } export function defaultLdapPortForTlsMode(tlsMode: TlsMode): number { if (tlsMode === TlsMode.Disabled) { return 389 } else { return 636 } } ================================================ FILE: warpgate-web/src/admin/config/target-groups/CreateTargetGroup.svelte ================================================

add a target group

{#if error} {error} {/if}
{ e.preventDefault() save() }}> Optional theme color for visual organization
{#each VALID_CHOICES as value (value)} {/each}
Create Cancel
================================================ FILE: warpgate-web/src/admin/config/target-groups/TargetGroup.svelte ================================================ {#if error} {error} {/if} {#if group}

{group.name}

Target group
{ e.preventDefault() update() }}> Optional Bootstrap theme color for visual organization
{#each VALID_CHOICES as value (value)} {/each}
Update
{/if}
================================================ FILE: warpgate-web/src/admin/config/target-groups/TargetGroups.svelte ================================================

target groups

Add a group
{#snippet empty()} {/snippet} {#snippet item(group)}
{#if group.color} {/if} {group.name}
{#if group.description} {group.description} {/if}
{/snippet}
================================================ FILE: warpgate-web/src/admin/config/target-groups/common.ts ================================================ import type { BootstrapThemeColor } from 'gateway/lib/api' export const VALID_COLORS: BootstrapThemeColor[] = ['Primary', 'Secondary', 'Success', 'Danger', 'Warning', 'Info', 'Light', 'Dark'] export const VALID_CHOICES = ['' as (BootstrapThemeColor | ''), ...VALID_COLORS] ================================================ FILE: warpgate-web/src/admin/config/targets/ChooseTargetKind.svelte ================================================

add a target

{#each kinds as kind (kind.value)} {#snippet addonSnippet()} {#if kind.experimental} Experimental {/if} {/snippet} {/each}
================================================ FILE: warpgate-web/src/admin/config/targets/CreateTarget.svelte ================================================
{#if !$adminPermissions.targetsCreate} You do not have permission to create targets. {/if} {#if error} {error} {/if}

add a target

{ create() e.preventDefault() }}> {#if groups.length > 0} {/if}
================================================ FILE: warpgate-web/src/admin/config/targets/Target.svelte ================================================
{#if target} connectionsInstructionsModalOpen = false}> Access instructions {#if target.options.kind === 'Ssh' || target.options.kind === 'MySql' || target.options.kind === 'Postgres' || target.options.kind === 'Kubernetes'} {#snippet children(users)} {/snippet} {/if} {#key connectionsInstructionsModalOpen} {/key}

{target.name}

{#if target.options.kind === 'MySql'} MySQL target {/if} {#if target.options.kind === 'Postgres'} PostgreSQL target {/if} {#if target.options.kind === 'Ssh'} SSH target {/if} {#if target.options.kind === 'Http'} HTTP target {/if} {#if target.options.kind === 'Kubernetes'} Kubernetes target {/if}

Configuration

0} class:col-md-12={!groups.length}>
{#if groups.length > 0}
{/if}
{#if target.options.kind === 'Ssh'} {/if} {#if target.options.kind === 'Http'} {#if $serverInfo?.externalHost} {/if} {/if} {#if target.options.kind === 'MySql' || target.options.kind === 'Postgres'}
{/if} {#if target.options.kind === 'Kubernetes'}
Authentication
{#if target.options.auth.kind === 'Certificate'} {/if} {#if target.options.auth.kind === 'Token'} {/if} {/if}

Allow access for roles

{#snippet children(roles)}
{#each roles as role (role.id)} {/each}
{/snippet}

Advanced

{#if target.options.kind === 'Postgres'} How long an authenticated session can remain idle before requiring re-authentication. Examples: 30m, 1h, 2h30m. Leave empty for default (10m). {/if} {#if target.options.kind === 'MySql' || target.options.kind === 'Postgres'} Default database name used in connection examples. This is only for display purposes and does not restrict which databases users can access. Leave empty to use the global default. {/if}
{/if}
{#if error} {error} {/if}
Update configuration Remove
================================================ FILE: warpgate-web/src/admin/config/targets/Targets.svelte ================================================

targets

{#if groups.length > 0} {selectedGroup?.name ?? 'All groups'} { selectedGroup = undefined }}> All groups {#each groups as group (group.id)} { selectedGroup = group }} class="d-flex align-items-center gap-2"> {#if group.color} {/if} {group.name} {/each} {/if} Add a target
{#if error} {error} {/if} {#key selectedGroup} {#snippet empty()} {/snippet} {#snippet item(target)}
{#if target.groupId} {@const group = groups.find(g => g.id === target.groupId)} {#if group} {#if group.color} {/if} {group.name} {/if} {/if} {target.name}
{#if target.description} {target.description} {/if}
{#if target.options.kind === TargetKind.Http} HTTP {/if} {#if target.options.kind === TargetKind.MySql} MySQL {/if} {#if target.options.kind === TargetKind.Postgres} PostgreSQL {/if} {#if target.options.kind === TargetKind.Ssh} SSH {/if} {#if target.options.kind === TargetKind.Kubernetes} Kubernetes {/if}
{/snippet}
{/key}
================================================ FILE: warpgate-web/src/admin/config/targets/ssh/KeyChecker.svelte ================================================
{#if _state.state === 'initializing'} Looking for trusted keys... {/if} {#if _state.state === 'not-checked'} {#if _state.hasTrustedKeys} There is a saved trusted key {:else} There are no trusted host keys yet {/if} {/if} {#if _state.state === 'checking'} {#if _state.previousResult} {:else} Retrieving remote host key... {/if} {/if} {#if _state.state === 'ready'} {/if} {#if _state.state === 'error'} {_state.error} {/if}
{#if _state.state === 'ready' && _state.result.state === 'key-unknown'} Trust {/if} {#if _state.state === 'ready' && _state.result.state === 'key-invalid'} Trust the new key {/if} {#if _state.state === 'ready' || _state.state === 'checking' && _state.previousResult} Recheck {:else} Check host key {/if}
================================================ FILE: warpgate-web/src/admin/config/targets/ssh/KeyCheckerResult.svelte ================================================ {#if result.state === 'key-valid'} Remote host key is trusted {/if} {#if result.state === 'key-unknown'}
Remote host key is not trusted yet
{result.actualKey} 
{/if} {#if result.state === 'key-invalid'}
Remote host key has changed!
{#if result.trustedKeys.length} Known trusted keys:
{#each result.trustedKeys as key (key)}
{key} 
{/each}
{/if} Current remote key:
{result.actualKey} 
{/if} ================================================ FILE: warpgate-web/src/admin/config/targets/ssh/Options.svelte ================================================

Connection

hostKeyCheckInvalidated = true} />
hostKeyCheckInvalidated = true} />

Authentication

{#if $adminPermissions.targetsEdit}
{#if !hostKeyCheckInvalidated} {:else} Save changes to see the host key validation status {/if}
{/if}
{#if options.auth.kind === 'PublicKey'} {/if} {#if options.auth.kind === 'Password'} {/if}
================================================ FILE: warpgate-web/src/admin/index.html ================================================ Warpgate
================================================ FILE: warpgate-web/src/admin/index.ts ================================================ import { mount } from 'svelte' import '../theme' import App from './App.svelte' mount(App, { target: document.getElementById('app')!, }) export { } ================================================ FILE: warpgate-web/src/admin/lib/PermissionGate.svelte ================================================ {#if $adminPermissions[perm]} {:else} {#if message} {message} {/if} {/if} ================================================ FILE: warpgate-web/src/admin/lib/api.ts ================================================ import { DefaultApi, Configuration, ResponseError } from './api-client/dist' const configuration = new Configuration({ basePath: '/@warpgate/admin/api', }) export const api = new DefaultApi(configuration) export * from './api-client' export async function stringifyError (err: ResponseError): Promise { return `API error: ${await err.response.text()}` } ================================================ FILE: warpgate-web/src/admin/lib/openapi-schema.json ================================================ { "openapi": "3.0.0", "info": { "title": "Warpgate Web Admin", "version": "v0.21.1-5-ga76ef2bb-modified" }, "servers": [ { "url": "/@warpgate/admin/api" } ], "tags": [], "paths": { "/sessions": { "get": { "parameters": [ { "name": "offset", "schema": { "type": "integer", "format": "uint64" }, "in": "query", "required": false, "deprecated": false, "explode": true }, { "name": "limit", "schema": { "type": "integer", "format": "uint64" }, "in": "query", "required": false, "deprecated": false, "explode": true }, { "name": "active_only", "schema": { "type": "boolean" }, "in": "query", "required": false, "deprecated": false, "explode": true }, { "name": "logged_in_only", "schema": { "type": "boolean" }, "in": "query", "required": false, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/PaginatedResponse_SessionSnapshot" } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_sessions" }, "delete": { "responses": { "201": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "close_all_sessions" } }, "/sessions/{id}": { "get": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/SessionSnapshot" } } } }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_session" } }, "/sessions/{id}/recordings": { "get": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Recording" } } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_session_recordings" } }, "/sessions/{id}/close": { "post": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "201": { "description": "" }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "close_session" } }, "/recordings/{id}": { "get": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/Recording" } } } }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_recording" } }, "/recordings/{id}/kubernetes": { "get": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/KubernetesRecordingItem" } } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_kubernetes_recording" } }, "/roles": { "get": { "parameters": [ { "name": "search", "schema": { "type": "string" }, "in": "query", "required": false, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Role" } } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_roles" }, "post": { "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/RoleDataRequest" } } }, "required": true }, "responses": { "201": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/Role" } } } }, "400": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "string" } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "create_role" } }, "/role/{id}": { "get": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/Role" } } } }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_role" }, "put": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/RoleDataRequest" } } }, "required": true }, "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/Role" } } } }, "403": { "description": "" }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "update_role" }, "delete": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "204": { "description": "" }, "403": { "description": "" }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "delete_role" } }, "/role/{id}/targets": { "get": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Target" } } } } }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_role_targets" } }, "/role/{id}/users": { "get": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/User" } } } } }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_role_users" } }, "/admin-roles": { "get": { "parameters": [ { "name": "search", "schema": { "type": "string" }, "in": "query", "required": false, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/AdminRole" } } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_admin_roles" }, "post": { "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/AdminRoleDataRequest" } } }, "required": true }, "responses": { "201": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/AdminRole" } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "create_admin_role" } }, "/admin-roles/{id}": { "get": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/AdminRole" } } } }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_admin_role" }, "put": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/AdminRoleDataRequest" } } }, "required": true }, "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/AdminRole" } } } }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "update_admin_role" }, "delete": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "204": { "description": "" }, "403": { "description": "" }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "delete_admin_role" } }, "/admin-roles/{id}/users": { "get": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/User" } } } } }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_admin_role_users" } }, "/tickets": { "get": { "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Ticket" } } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_tickets" }, "post": { "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/CreateTicketRequest" } } }, "required": true }, "responses": { "201": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/TicketAndSecret" } } } }, "400": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "string" } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "create_ticket" } }, "/tickets/{id}": { "delete": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "204": { "description": "" }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "delete_ticket" } }, "/ssh/known-hosts": { "post": { "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/AddSshKnownHostRequest" } } }, "required": true }, "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/SSHKnownHost" } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "add_ssh_known_host" }, "get": { "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/SSHKnownHost" } } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_ssh_known_hosts" } }, "/ssh/known-hosts/{id}": { "delete": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "204": { "description": "" }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "delete_ssh_known_host" } }, "/ssh/own-keys": { "get": { "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/SSHKey" } } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_ssh_own_keys" } }, "/logs": { "post": { "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/GetLogsRequest" } } }, "required": true }, "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/LogEntry" } } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_logs" } }, "/targets": { "get": { "parameters": [ { "name": "search", "schema": { "type": "string" }, "in": "query", "required": false, "deprecated": false, "explode": true }, { "name": "group_id", "schema": { "type": "string", "format": "uuid" }, "in": "query", "required": false, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Target" } } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_targets" }, "post": { "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/TargetDataRequest" } } }, "required": true }, "responses": { "201": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/Target" } } } }, "409": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "string" } } } }, "400": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "string" } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "create_target" } }, "/targets/{id}": { "get": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/Target" } } } }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_target" }, "put": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/TargetDataRequest" } } }, "required": true }, "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/Target" } } } }, "400": { "description": "" }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "update_target" }, "delete": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "204": { "description": "" }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "delete_target" } }, "/targets/{id}/known-ssh-host-keys": { "get": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/SSHKnownHost" } } } } }, "400": { "description": "" }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_ssh_target_known_ssh_host_keys" } }, "/targets/{id}/roles": { "get": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Role" } } } } }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_target_roles" } }, "/targets/{id}/roles/{role_id}": { "post": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true }, { "name": "role_id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "201": { "description": "" }, "409": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "add_target_role" }, "delete": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true }, { "name": "role_id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "204": { "description": "" }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "delete_target_role" } }, "/target-groups": { "get": { "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/TargetGroup" } } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "list_target_groups" }, "post": { "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/TargetGroupDataRequest" } } }, "required": true }, "responses": { "201": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/TargetGroup" } } } }, "409": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "string" } } } }, "400": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "string" } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "create_target_group" } }, "/target-groups/{id}": { "get": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/TargetGroup" } } } }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_target_group" }, "put": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/TargetGroupDataRequest" } } }, "required": true }, "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/TargetGroup" } } } }, "400": { "description": "" }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "update_target_group" }, "delete": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "204": { "description": "" }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "delete_target_group" } }, "/users": { "get": { "parameters": [ { "name": "search", "schema": { "type": "string" }, "in": "query", "required": false, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/User" } } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_users" }, "post": { "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/CreateUserRequest" } } }, "required": true }, "responses": { "201": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/User" } } } }, "400": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "string" } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "create_user" } }, "/users/{id}": { "get": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/User" } } } }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_user" }, "put": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/UserDataRequest" } } }, "required": true }, "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/User" } } } }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "update_user" }, "delete": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "204": { "description": "" }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "delete_user" } }, "/users/{id}/ldap-link/unlink": { "post": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/User" } } } }, "404": { "description": "" }, "400": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "string" } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "unlink_user_from_ldap" } }, "/users/{id}/ldap-link/auto-link": { "post": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/User" } } } }, "404": { "description": "" }, "400": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "string" } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "auto_link_user_to_ldap" } }, "/users/{id}/roles": { "get": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Role" } } } } }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_user_roles" } }, "/users/{id}/roles/{role_id}": { "post": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true }, { "name": "role_id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "201": { "description": "" }, "409": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "add_user_role" }, "delete": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true }, { "name": "role_id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "204": { "description": "" }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "delete_user_role" } }, "/users/{id}/admin-roles": { "get": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/AdminRole" } } } } }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_user_admin_roles" } }, "/users/{id}/admin-roles/{role_id}": { "post": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true }, { "name": "role_id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "201": { "description": "" }, "409": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "add_user_admin_role" }, "delete": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true }, { "name": "role_id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "204": { "description": "" }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "delete_user_admin_role" } }, "/users/{user_id}/credentials/passwords": { "get": { "parameters": [ { "name": "user_id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ExistingPasswordCredential" } } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_password_credentials" }, "post": { "parameters": [ { "name": "user_id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/NewPasswordCredential" } } }, "required": true }, "responses": { "201": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/ExistingPasswordCredential" } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "create_password_credential" } }, "/users/{user_id}/credentials/passwords/{id}": { "delete": { "parameters": [ { "name": "user_id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true }, { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "204": { "description": "" }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "delete_password_credential" } }, "/users/{user_id}/credentials/sso": { "get": { "parameters": [ { "name": "user_id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ExistingSsoCredential" } } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_sso_credentials" }, "post": { "parameters": [ { "name": "user_id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/NewSsoCredential" } } }, "required": true }, "responses": { "201": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/ExistingSsoCredential" } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "create_sso_credential" } }, "/users/{user_id}/credentials/sso/{id}": { "put": { "parameters": [ { "name": "user_id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true }, { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/NewSsoCredential" } } }, "required": true }, "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/ExistingSsoCredential" } } } }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "update_sso_credential" }, "delete": { "parameters": [ { "name": "user_id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true }, { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "204": { "description": "" }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "delete_sso_credential" } }, "/users/{user_id}/credentials/public-keys": { "get": { "parameters": [ { "name": "user_id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ExistingPublicKeyCredential" } } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_public_key_credentials" }, "post": { "parameters": [ { "name": "user_id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/NewPublicKeyCredential" } } }, "required": true }, "responses": { "201": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/ExistingPublicKeyCredential" } } } }, "403": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "string" } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "create_public_key_credential" } }, "/users/{user_id}/credentials/public-keys/{id}": { "put": { "parameters": [ { "name": "user_id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true }, { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/NewPublicKeyCredential" } } }, "required": true }, "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/ExistingPublicKeyCredential" } } } }, "404": { "description": "" }, "403": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "string" } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "update_public_key_credential" }, "delete": { "parameters": [ { "name": "user_id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true }, { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "204": { "description": "" }, "404": { "description": "" }, "403": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "string" } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "delete_public_key_credential" } }, "/users/{user_id}/credentials/otp": { "get": { "parameters": [ { "name": "user_id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ExistingOtpCredential" } } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_otp_credentials" }, "post": { "parameters": [ { "name": "user_id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/NewOtpCredential" } } }, "required": true }, "responses": { "201": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/ExistingOtpCredential" } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "create_otp_credential" } }, "/users/{user_id}/credentials/otp/{id}": { "delete": { "parameters": [ { "name": "user_id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true }, { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "204": { "description": "" }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "delete_otp_credential" } }, "/ldap-servers": { "get": { "parameters": [ { "name": "search", "schema": { "type": "string" }, "in": "query", "required": false, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/LdapServerResponse" } } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_ldap_servers" }, "post": { "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/CreateLdapServerRequest" } } }, "required": true }, "responses": { "201": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/LdapServerResponse" } } } }, "409": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "string" } } } }, "400": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "string" } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "create_ldap_server" } }, "/ldap-servers/test": { "post": { "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/TestLdapServerRequest" } } }, "required": true }, "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/TestLdapServerResponse" } } } }, "400": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "string" } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "test_ldap_server_connection" } }, "/ldap-servers/{id}": { "get": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/LdapServerResponse" } } } }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_ldap_server" }, "put": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/UpdateLdapServerRequest" } } }, "required": true }, "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/LdapServerResponse" } } } }, "404": { "description": "" }, "400": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "string" } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "update_ldap_server" }, "delete": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "204": { "description": "" }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "delete_ldap_server" } }, "/ldap-servers/{id}/users": { "get": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/LdapUserResponse" } } } } }, "404": { "description": "" }, "400": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "string" } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_ldap_users" } }, "/ldap-servers/{id}/import-users": { "post": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/ImportLdapUsersRequest" } } }, "required": true }, "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "type": "string" } } } } }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "import_ldap_users" } }, "/parameters": { "get": { "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/ParameterValues" } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_parameters" }, "put": { "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/ParameterUpdate" } } }, "required": true }, "responses": { "201": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "update_parameters" } }, "/ssh/check-host-key": { "post": { "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/CheckSshHostKeyRequest" } } }, "required": true }, "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/CheckSshHostKeyResponseBody" } } } }, "500": { "description": "", "content": { "text/plain; charset=utf-8": { "schema": { "type": "string" } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "check_ssh_host_key" } }, "/users/{user_id}/credentials/certificates": { "get": { "parameters": [ { "name": "user_id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ExistingCertificateCredential" } } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_certificate_credentials" }, "post": { "parameters": [ { "name": "user_id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/IssueCertificateCredentialRequest" } } }, "required": true }, "responses": { "201": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/IssuedCertificateCredential" } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "issue_certificate_credential" } }, "/users/{user_id}/credentials/certificates/{id}": { "patch": { "parameters": [ { "name": "user_id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true }, { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/UpdateCertificateCredential" } } }, "required": true }, "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/ExistingCertificateCredential" } } } }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "update_certificate_credential" }, "delete": { "parameters": [ { "name": "user_id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true }, { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "204": { "description": "" }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "revoke_certificate_credential" } } }, "components": { "schemas": { "AddSshKnownHostRequest": { "type": "object", "title": "AddSshKnownHostRequest", "required": [ "host", "port", "key_type", "key_base64" ], "properties": { "host": { "type": "string" }, "port": { "type": "integer", "format": "int32" }, "key_type": { "type": "string" }, "key_base64": { "type": "string" } } }, "AdminRole": { "type": "object", "title": "AdminRole", "required": [ "id", "name", "description", "targets_create", "targets_edit", "targets_delete", "users_create", "users_edit", "users_delete", "access_roles_create", "access_roles_edit", "access_roles_delete", "access_roles_assign", "sessions_view", "sessions_terminate", "recordings_view", "tickets_create", "tickets_delete", "config_edit", "admin_roles_manage" ], "properties": { "id": { "type": "string", "format": "uuid" }, "name": { "type": "string" }, "description": { "type": "string" }, "targets_create": { "type": "boolean" }, "targets_edit": { "type": "boolean" }, "targets_delete": { "type": "boolean" }, "users_create": { "type": "boolean" }, "users_edit": { "type": "boolean" }, "users_delete": { "type": "boolean" }, "access_roles_create": { "type": "boolean" }, "access_roles_edit": { "type": "boolean" }, "access_roles_delete": { "type": "boolean" }, "access_roles_assign": { "type": "boolean" }, "sessions_view": { "type": "boolean" }, "sessions_terminate": { "type": "boolean" }, "recordings_view": { "type": "boolean" }, "tickets_create": { "type": "boolean" }, "tickets_delete": { "type": "boolean" }, "config_edit": { "type": "boolean" }, "admin_roles_manage": { "type": "boolean" } } }, "AdminRoleDataRequest": { "type": "object", "title": "AdminRoleDataRequest", "required": [ "name", "targets_create", "targets_edit", "targets_delete", "users_create", "users_edit", "users_delete", "access_roles_create", "access_roles_edit", "access_roles_delete", "access_roles_assign", "sessions_view", "sessions_terminate", "recordings_view", "tickets_create", "tickets_delete", "config_edit", "admin_roles_manage" ], "properties": { "name": { "type": "string" }, "description": { "type": "string" }, "targets_create": { "type": "boolean" }, "targets_edit": { "type": "boolean" }, "targets_delete": { "type": "boolean" }, "users_create": { "type": "boolean" }, "users_edit": { "type": "boolean" }, "users_delete": { "type": "boolean" }, "access_roles_create": { "type": "boolean" }, "access_roles_edit": { "type": "boolean" }, "access_roles_delete": { "type": "boolean" }, "access_roles_assign": { "type": "boolean" }, "sessions_view": { "type": "boolean" }, "sessions_terminate": { "type": "boolean" }, "recordings_view": { "type": "boolean" }, "tickets_create": { "type": "boolean" }, "tickets_delete": { "type": "boolean" }, "config_edit": { "type": "boolean" }, "admin_roles_manage": { "type": "boolean" } } }, "BootstrapThemeColor": { "type": "string", "enum": [ "Primary", "Secondary", "Success", "Danger", "Warning", "Info", "Light", "Dark" ] }, "CheckSshHostKeyRequest": { "type": "object", "title": "CheckSshHostKeyRequest", "required": [ "host", "port" ], "properties": { "host": { "type": "string" }, "port": { "type": "integer", "format": "uint16" } } }, "CheckSshHostKeyResponseBody": { "type": "object", "title": "CheckSshHostKeyResponseBody", "required": [ "remote_key_type", "remote_key_base64" ], "properties": { "remote_key_type": { "type": "string" }, "remote_key_base64": { "type": "string" } } }, "CreateLdapServerRequest": { "type": "object", "title": "CreateLdapServerRequest", "required": [ "name", "host", "bind_dn", "bind_password" ], "properties": { "name": { "type": "string" }, "host": { "type": "string" }, "port": { "type": "integer", "format": "int32", "default": 389 }, "bind_dn": { "type": "string" }, "bind_password": { "type": "string" }, "user_filter": { "type": "string", "default": "(objectClass=person)" }, "tls_mode": { "default": "Preferred", "allOf": [ { "$ref": "#/components/schemas/TlsMode" }, { "default": "Preferred" } ] }, "tls_verify": { "type": "boolean", "default": true }, "enabled": { "type": "boolean", "default": true }, "auto_link_sso_users": { "type": "boolean", "default": false }, "description": { "type": "string" }, "username_attribute": { "default": "Cn", "allOf": [ { "$ref": "#/components/schemas/LdapUsernameAttribute" }, { "default": "Cn" } ] }, "ssh_key_attribute": { "type": "string", "default": "sshPublicKey" }, "uuid_attribute": { "type": "string", "default": "" } } }, "CreateTicketRequest": { "type": "object", "title": "CreateTicketRequest", "required": [ "username", "target_name" ], "properties": { "username": { "type": "string" }, "target_name": { "type": "string" }, "expiry": { "type": "string", "format": "date-time" }, "number_of_uses": { "type": "integer", "format": "int16" }, "description": { "type": "string" } } }, "CreateUserRequest": { "type": "object", "title": "CreateUserRequest", "required": [ "username" ], "properties": { "username": { "type": "string" }, "description": { "type": "string" } } }, "CredentialKind": { "type": "string", "enum": [ "Password", "PublicKey", "Certificate", "Totp", "Sso", "WebUserApproval" ] }, "ExistingCertificateCredential": { "type": "object", "title": "ExistingCertificateCredential", "required": [ "id", "label", "fingerprint" ], "properties": { "id": { "type": "string", "format": "uuid" }, "label": { "type": "string" }, "date_added": { "type": "string", "format": "date-time" }, "last_used": { "type": "string", "format": "date-time" }, "fingerprint": { "type": "string" } } }, "ExistingOtpCredential": { "type": "object", "title": "ExistingOtpCredential", "required": [ "id" ], "properties": { "id": { "type": "string", "format": "uuid" } } }, "ExistingPasswordCredential": { "type": "object", "title": "ExistingPasswordCredential", "required": [ "id" ], "properties": { "id": { "type": "string", "format": "uuid" } } }, "ExistingPublicKeyCredential": { "type": "object", "title": "ExistingPublicKeyCredential", "required": [ "id", "label", "openssh_public_key" ], "properties": { "id": { "type": "string", "format": "uuid" }, "label": { "type": "string" }, "date_added": { "type": "string", "format": "date-time" }, "last_used": { "type": "string", "format": "date-time" }, "openssh_public_key": { "type": "string" } } }, "ExistingSsoCredential": { "type": "object", "title": "ExistingSsoCredential", "required": [ "id", "email" ], "properties": { "id": { "type": "string", "format": "uuid" }, "provider": { "type": "string" }, "email": { "type": "string" } } }, "GetLogsRequest": { "type": "object", "title": "GetLogsRequest", "properties": { "before": { "type": "string", "format": "date-time" }, "after": { "type": "string", "format": "date-time" }, "limit": { "type": "integer", "format": "uint64" }, "session_id": { "type": "string", "format": "uuid" }, "username": { "type": "string" }, "search": { "type": "string" } } }, "ImportLdapUsersRequest": { "type": "object", "title": "ImportLdapUsersRequest", "required": [ "dns" ], "properties": { "dns": { "type": "array", "items": { "type": "string" } } } }, "IssueCertificateCredentialRequest": { "type": "object", "title": "IssueCertificateCredentialRequest", "required": [ "label", "public_key_pem" ], "properties": { "label": { "type": "string" }, "public_key_pem": { "type": "string" } } }, "IssuedCertificateCredential": { "type": "object", "title": "IssuedCertificateCredential", "required": [ "credential", "certificate_pem" ], "properties": { "credential": { "$ref": "#/components/schemas/ExistingCertificateCredential" }, "certificate_pem": { "type": "string" } } }, "KubernetesRecordingItem": { "type": "object", "title": "KubernetesRecordingItem", "required": [ "timestamp", "request_method", "request_path", "request_body", "response_body" ], "properties": { "timestamp": { "type": "string", "format": "date-time" }, "request_method": { "type": "string" }, "request_path": { "type": "string" }, "request_body": {}, "response_status": { "type": "integer", "format": "uint16" }, "response_body": {} } }, "KubernetesTargetAuth": { "type": "object", "oneOf": [ { "$ref": "#/components/schemas/KubernetesTargetAuth_KubernetesTargetTokenAuth" }, { "$ref": "#/components/schemas/KubernetesTargetAuth_KubernetesTargetCertificateAuth" } ], "discriminator": { "propertyName": "kind", "mapping": { "Token": "#/components/schemas/KubernetesTargetAuth_KubernetesTargetTokenAuth", "Certificate": "#/components/schemas/KubernetesTargetAuth_KubernetesTargetCertificateAuth" } } }, "KubernetesTargetAuth_KubernetesTargetCertificateAuth": { "allOf": [ { "type": "object", "required": [ "kind" ], "properties": { "kind": { "type": "string", "enum": [ "Certificate" ], "example": "Certificate" } } }, { "$ref": "#/components/schemas/KubernetesTargetCertificateAuth" } ] }, "KubernetesTargetAuth_KubernetesTargetTokenAuth": { "allOf": [ { "type": "object", "required": [ "kind" ], "properties": { "kind": { "type": "string", "enum": [ "Token" ], "example": "Token" } } }, { "$ref": "#/components/schemas/KubernetesTargetTokenAuth" } ] }, "KubernetesTargetCertificateAuth": { "type": "object", "title": "KubernetesTargetCertificateAuth", "required": [ "certificate", "private_key" ], "properties": { "certificate": { "type": "string" }, "private_key": { "type": "string" } } }, "KubernetesTargetTokenAuth": { "type": "object", "title": "KubernetesTargetTokenAuth", "required": [ "token" ], "properties": { "token": { "type": "string" } } }, "LdapServerResponse": { "type": "object", "title": "LdapServerResponse", "required": [ "id", "name", "host", "port", "bind_dn", "user_filter", "base_dns", "tls_mode", "tls_verify", "enabled", "auto_link_sso_users", "description", "username_attribute", "ssh_key_attribute", "uuid_attribute" ], "properties": { "id": { "type": "string", "format": "uuid" }, "name": { "type": "string" }, "host": { "type": "string" }, "port": { "type": "integer", "format": "int32" }, "bind_dn": { "type": "string" }, "user_filter": { "type": "string" }, "base_dns": { "type": "array", "items": { "type": "string" } }, "tls_mode": { "$ref": "#/components/schemas/TlsMode" }, "tls_verify": { "type": "boolean" }, "enabled": { "type": "boolean" }, "auto_link_sso_users": { "type": "boolean" }, "description": { "type": "string" }, "username_attribute": { "$ref": "#/components/schemas/LdapUsernameAttribute" }, "ssh_key_attribute": { "type": "string" }, "uuid_attribute": { "type": "string" } } }, "LdapUserResponse": { "type": "object", "title": "LdapUserResponse", "required": [ "username", "dn" ], "properties": { "username": { "type": "string" }, "email": { "type": "string" }, "display_name": { "type": "string" }, "dn": { "type": "string" } } }, "LdapUsernameAttribute": { "type": "string", "enum": [ "Cn", "Uid", "Email", "UserPrincipalName", "SamAccountName" ] }, "LogEntry": { "type": "object", "title": "LogEntry", "required": [ "id", "text", "values", "timestamp", "session_id" ], "properties": { "id": { "type": "string", "format": "uuid" }, "text": { "type": "string" }, "values": {}, "timestamp": { "type": "string", "format": "date-time" }, "session_id": { "type": "string", "format": "uuid" }, "username": { "type": "string" } } }, "NewOtpCredential": { "type": "object", "title": "NewOtpCredential", "required": [ "secret_key" ], "properties": { "secret_key": { "type": "array", "items": { "type": "integer", "format": "uint8" } } } }, "NewPasswordCredential": { "type": "object", "title": "NewPasswordCredential", "required": [ "password" ], "properties": { "password": { "type": "string" } } }, "NewPublicKeyCredential": { "type": "object", "title": "NewPublicKeyCredential", "required": [ "label", "openssh_public_key" ], "properties": { "label": { "type": "string" }, "openssh_public_key": { "type": "string" } } }, "NewSsoCredential": { "type": "object", "title": "NewSsoCredential", "required": [ "email" ], "properties": { "provider": { "type": "string" }, "email": { "type": "string" } } }, "PaginatedResponse_SessionSnapshot": { "type": "object", "title": "PaginatedResponse_SessionSnapshot", "required": [ "items", "offset", "total" ], "properties": { "items": { "type": "array", "items": { "$ref": "#/components/schemas/SessionSnapshot" } }, "offset": { "type": "integer", "format": "uint64" }, "total": { "type": "integer", "format": "uint64" } } }, "ParameterUpdate": { "type": "object", "title": "ParameterUpdate", "required": [ "allow_own_credential_management" ], "properties": { "allow_own_credential_management": { "type": "boolean" }, "rate_limit_bytes_per_second": { "type": "integer", "format": "uint32" }, "ssh_client_auth_publickey": { "type": "boolean" }, "ssh_client_auth_password": { "type": "boolean" }, "ssh_client_auth_keyboard_interactive": { "type": "boolean" }, "minimize_password_login": { "type": "boolean" } } }, "ParameterValues": { "type": "object", "title": "ParameterValues", "required": [ "allow_own_credential_management", "ssh_client_auth_publickey", "ssh_client_auth_password", "ssh_client_auth_keyboard_interactive", "minimize_password_login" ], "properties": { "allow_own_credential_management": { "type": "boolean" }, "rate_limit_bytes_per_second": { "type": "integer", "format": "uint32" }, "ssh_client_auth_publickey": { "type": "boolean" }, "ssh_client_auth_password": { "type": "boolean" }, "ssh_client_auth_keyboard_interactive": { "type": "boolean" }, "minimize_password_login": { "type": "boolean" } } }, "Recording": { "type": "object", "title": "Recording", "required": [ "id", "name", "started", "session_id", "kind", "metadata" ], "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" }, "metadata": { "type": "string" } } }, "RecordingKind": { "type": "string", "enum": [ "Terminal", "Traffic", "Kubernetes" ] }, "Role": { "type": "object", "title": "Role", "required": [ "id", "name", "description" ], "properties": { "id": { "type": "string", "format": "uuid" }, "name": { "type": "string" }, "description": { "type": "string" } } }, "RoleDataRequest": { "type": "object", "title": "RoleDataRequest", "required": [ "name" ], "properties": { "name": { "type": "string" }, "description": { "type": "string" } } }, "SSHKey": { "type": "object", "title": "SSHKey", "required": [ "kind", "public_key_base64" ], "properties": { "kind": { "type": "string" }, "public_key_base64": { "type": "string" } } }, "SSHKnownHost": { "type": "object", "title": "SSHKnownHost", "required": [ "id", "host", "port", "key_type", "key_base64" ], "properties": { "id": { "type": "string", "format": "uuid" }, "host": { "type": "string" }, "port": { "type": "integer", "format": "int32" }, "key_type": { "type": "string" }, "key_base64": { "type": "string" } } }, "SSHTargetAuth": { "type": "object", "oneOf": [ { "$ref": "#/components/schemas/SSHTargetAuth_SshTargetPasswordAuth" }, { "$ref": "#/components/schemas/SSHTargetAuth_SshTargetPublicKeyAuth" } ], "discriminator": { "propertyName": "kind", "mapping": { "Password": "#/components/schemas/SSHTargetAuth_SshTargetPasswordAuth", "PublicKey": "#/components/schemas/SSHTargetAuth_SshTargetPublicKeyAuth" } } }, "SSHTargetAuth_SshTargetPasswordAuth": { "allOf": [ { "type": "object", "required": [ "kind" ], "properties": { "kind": { "type": "string", "enum": [ "Password" ], "example": "Password" } } }, { "$ref": "#/components/schemas/SshTargetPasswordAuth" } ] }, "SSHTargetAuth_SshTargetPublicKeyAuth": { "allOf": [ { "type": "object", "required": [ "kind" ], "properties": { "kind": { "type": "string", "enum": [ "PublicKey" ], "example": "PublicKey" } } }, { "$ref": "#/components/schemas/SshTargetPublicKeyAuth" } ] }, "SessionSnapshot": { "type": "object", "title": "SessionSnapshot", "required": [ "id", "started", "protocol" ], "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" }, "protocol": { "type": "string" } } }, "SshTargetPasswordAuth": { "type": "object", "title": "SshTargetPasswordAuth", "required": [ "password" ], "properties": { "password": { "type": "string" } } }, "SshTargetPublicKeyAuth": { "type": "object", "title": "SshTargetPublicKeyAuth" }, "Target": { "type": "object", "title": "Target", "required": [ "id", "name", "description", "allow_roles", "options" ], "properties": { "id": { "type": "string", "format": "uuid" }, "name": { "type": "string" }, "description": { "type": "string" }, "allow_roles": { "type": "array", "items": { "type": "string" } }, "options": { "$ref": "#/components/schemas/TargetOptions" }, "rate_limit_bytes_per_second": { "type": "integer", "format": "uint32" }, "group_id": { "type": "string", "format": "uuid" } } }, "TargetDataRequest": { "type": "object", "title": "TargetDataRequest", "required": [ "name", "options" ], "properties": { "name": { "type": "string" }, "description": { "type": "string" }, "options": { "$ref": "#/components/schemas/TargetOptions" }, "rate_limit_bytes_per_second": { "type": "integer", "format": "uint32" }, "group_id": { "type": "string", "format": "uuid" } } }, "TargetGroup": { "type": "object", "title": "TargetGroup", "required": [ "id", "name", "description" ], "properties": { "id": { "type": "string", "format": "uuid" }, "name": { "type": "string" }, "description": { "type": "string" }, "color": { "$ref": "#/components/schemas/BootstrapThemeColor" } } }, "TargetGroupDataRequest": { "type": "object", "title": "TargetGroupDataRequest", "required": [ "name" ], "properties": { "name": { "type": "string" }, "description": { "type": "string" }, "color": { "$ref": "#/components/schemas/BootstrapThemeColor" } } }, "TargetHTTPOptions": { "type": "object", "title": "TargetHTTPOptions", "required": [ "url", "tls" ], "properties": { "url": { "type": "string" }, "tls": { "$ref": "#/components/schemas/Tls" }, "headers": { "type": "object", "additionalProperties": { "type": "string" } }, "external_host": { "type": "string" } } }, "TargetKubernetesOptions": { "type": "object", "title": "TargetKubernetesOptions", "required": [ "cluster_url", "tls", "auth" ], "properties": { "cluster_url": { "type": "string" }, "tls": { "$ref": "#/components/schemas/Tls" }, "auth": { "$ref": "#/components/schemas/KubernetesTargetAuth" } } }, "TargetMySqlOptions": { "type": "object", "title": "TargetMySqlOptions", "required": [ "host", "port", "username", "tls" ], "properties": { "host": { "type": "string" }, "port": { "type": "integer", "format": "uint16" }, "username": { "type": "string" }, "password": { "type": "string" }, "tls": { "$ref": "#/components/schemas/Tls" }, "default_database_name": { "type": "string" } } }, "TargetOptions": { "type": "object", "oneOf": [ { "$ref": "#/components/schemas/TargetOptions_TargetSSHOptions" }, { "$ref": "#/components/schemas/TargetOptions_TargetHTTPOptions" }, { "$ref": "#/components/schemas/TargetOptions_TargetKubernetesOptions" }, { "$ref": "#/components/schemas/TargetOptions_TargetMySqlOptions" }, { "$ref": "#/components/schemas/TargetOptions_TargetPostgresOptions" } ], "discriminator": { "propertyName": "kind", "mapping": { "Ssh": "#/components/schemas/TargetOptions_TargetSSHOptions", "Http": "#/components/schemas/TargetOptions_TargetHTTPOptions", "Kubernetes": "#/components/schemas/TargetOptions_TargetKubernetesOptions", "MySql": "#/components/schemas/TargetOptions_TargetMySqlOptions", "Postgres": "#/components/schemas/TargetOptions_TargetPostgresOptions" } } }, "TargetOptions_TargetHTTPOptions": { "allOf": [ { "type": "object", "required": [ "kind" ], "properties": { "kind": { "type": "string", "enum": [ "Http" ], "example": "Http" } } }, { "$ref": "#/components/schemas/TargetHTTPOptions" } ] }, "TargetOptions_TargetKubernetesOptions": { "allOf": [ { "type": "object", "required": [ "kind" ], "properties": { "kind": { "type": "string", "enum": [ "Kubernetes" ], "example": "Kubernetes" } } }, { "$ref": "#/components/schemas/TargetKubernetesOptions" } ] }, "TargetOptions_TargetMySqlOptions": { "allOf": [ { "type": "object", "required": [ "kind" ], "properties": { "kind": { "type": "string", "enum": [ "MySql" ], "example": "MySql" } } }, { "$ref": "#/components/schemas/TargetMySqlOptions" } ] }, "TargetOptions_TargetPostgresOptions": { "allOf": [ { "type": "object", "required": [ "kind" ], "properties": { "kind": { "type": "string", "enum": [ "Postgres" ], "example": "Postgres" } } }, { "$ref": "#/components/schemas/TargetPostgresOptions" } ] }, "TargetOptions_TargetSSHOptions": { "allOf": [ { "type": "object", "required": [ "kind" ], "properties": { "kind": { "type": "string", "enum": [ "Ssh" ], "example": "Ssh" } } }, { "$ref": "#/components/schemas/TargetSSHOptions" } ] }, "TargetPostgresOptions": { "type": "object", "title": "TargetPostgresOptions", "required": [ "host", "port", "username", "tls" ], "properties": { "host": { "type": "string" }, "port": { "type": "integer", "format": "uint16" }, "username": { "type": "string" }, "password": { "type": "string" }, "tls": { "$ref": "#/components/schemas/Tls" }, "idle_timeout": { "type": "string" }, "default_database_name": { "type": "string" } } }, "TargetSSHOptions": { "type": "object", "title": "TargetSSHOptions", "required": [ "host", "port", "username", "auth" ], "properties": { "host": { "type": "string" }, "port": { "type": "integer", "format": "uint16" }, "username": { "type": "string" }, "allow_insecure_algos": { "type": "boolean" }, "auth": { "$ref": "#/components/schemas/SSHTargetAuth" } } }, "TestLdapServerRequest": { "type": "object", "title": "TestLdapServerRequest", "required": [ "host", "port", "bind_dn", "bind_password", "tls_mode", "tls_verify" ], "properties": { "host": { "type": "string" }, "port": { "type": "integer", "format": "int32" }, "bind_dn": { "type": "string" }, "bind_password": { "type": "string" }, "tls_mode": { "$ref": "#/components/schemas/TlsMode" }, "tls_verify": { "type": "boolean" } } }, "TestLdapServerResponse": { "type": "object", "title": "TestLdapServerResponse", "required": [ "success", "message" ], "properties": { "success": { "type": "boolean" }, "message": { "type": "string" }, "base_dns": { "type": "array", "items": { "type": "string" } } } }, "Ticket": { "type": "object", "title": "Ticket", "required": [ "id", "username", "description", "target", "created" ], "properties": { "id": { "type": "string", "format": "uuid" }, "username": { "type": "string" }, "description": { "type": "string" }, "target": { "type": "string" }, "uses_left": { "type": "integer", "format": "int16" }, "expiry": { "type": "string", "format": "date-time" }, "created": { "type": "string", "format": "date-time" } } }, "TicketAndSecret": { "type": "object", "title": "TicketAndSecret", "required": [ "ticket", "secret" ], "properties": { "ticket": { "$ref": "#/components/schemas/Ticket" }, "secret": { "type": "string" } } }, "Tls": { "type": "object", "title": "Tls", "required": [ "mode", "verify" ], "properties": { "mode": { "$ref": "#/components/schemas/TlsMode" }, "verify": { "type": "boolean" } } }, "TlsMode": { "type": "string", "enum": [ "Disabled", "Preferred", "Required" ] }, "UpdateCertificateCredential": { "type": "object", "title": "UpdateCertificateCredential", "required": [ "label" ], "properties": { "label": { "type": "string" } } }, "UpdateLdapServerRequest": { "type": "object", "title": "UpdateLdapServerRequest", "required": [ "name", "host", "port", "bind_dn", "user_filter", "tls_mode", "tls_verify", "enabled", "auto_link_sso_users", "username_attribute", "ssh_key_attribute", "uuid_attribute" ], "properties": { "name": { "type": "string" }, "host": { "type": "string" }, "port": { "type": "integer", "format": "int32" }, "bind_dn": { "type": "string" }, "bind_password": { "type": "string" }, "user_filter": { "type": "string" }, "tls_mode": { "$ref": "#/components/schemas/TlsMode" }, "tls_verify": { "type": "boolean" }, "enabled": { "type": "boolean" }, "auto_link_sso_users": { "type": "boolean" }, "description": { "type": "string" }, "username_attribute": { "$ref": "#/components/schemas/LdapUsernameAttribute" }, "ssh_key_attribute": { "type": "string" }, "uuid_attribute": { "type": "string" } } }, "User": { "type": "object", "title": "User", "required": [ "id", "username", "description" ], "properties": { "id": { "type": "string", "format": "uuid" }, "username": { "type": "string" }, "description": { "type": "string" }, "credential_policy": { "$ref": "#/components/schemas/UserRequireCredentialsPolicy" }, "rate_limit_bytes_per_second": { "type": "integer", "format": "int64" }, "ldap_server_id": { "type": "string", "format": "uuid" } } }, "UserDataRequest": { "type": "object", "title": "UserDataRequest", "required": [ "username" ], "properties": { "username": { "type": "string" }, "credential_policy": { "$ref": "#/components/schemas/UserRequireCredentialsPolicy" }, "description": { "type": "string" }, "rate_limit_bytes_per_second": { "type": "integer", "format": "uint32" } } }, "UserRequireCredentialsPolicy": { "type": "object", "title": "UserRequireCredentialsPolicy", "properties": { "http": { "type": "array", "items": { "$ref": "#/components/schemas/CredentialKind" } }, "kubernetes": { "type": "array", "items": { "$ref": "#/components/schemas/CredentialKind" } }, "ssh": { "type": "array", "items": { "$ref": "#/components/schemas/CredentialKind" } }, "mysql": { "type": "array", "items": { "$ref": "#/components/schemas/CredentialKind" } }, "postgres": { "type": "array", "items": { "$ref": "#/components/schemas/CredentialKind" } } } } }, "securitySchemes": { "CookieSecurityScheme": { "type": "apiKey", "name": "warpgate-http-session", "in": "cookie" }, "TokenSecurityScheme": { "type": "apiKey", "name": "X-Warpgate-Token", "in": "header" } } } } ================================================ FILE: warpgate-web/src/admin/lib/store.ts ================================================ import { derived } from 'svelte/store' import { serverInfo } from 'gateway/lib/store' import type { AdminPermissions } from 'gateway/lib/api' export interface AdminPermissionDef { key: string label: string category?: string deps?: string[] dangerous?: boolean } export const ADMIN_PERMISSIONS = [ { key: 'targetsCreate' as const, label: 'Create', category: 'Targets' as const, deps: ['targetsEdit'], }, { key: 'targetsEdit' as const, label: 'Edit', category: 'Targets' as const, }, { key: 'targetsDelete' as const, label: 'Delete', category: 'Targets' as const, deps: ['targetsCreate', 'targetsEdit'], }, { key: 'usersCreate' as const, label: 'Create', category: 'Users' as const, deps: ['usersEdit'], }, { key: 'usersEdit' as const, label: 'Edit', category: 'Users' as const, }, { key: 'usersDelete' as const, label: 'Delete', category: 'Users' as const, deps: ['usersCreate', 'usersEdit'], }, { key: 'accessRolesCreate' as const, label: 'Create', category: 'Access roles' as const, deps: ['accessRolesEdit'], }, { key: 'accessRolesEdit' as const, label: 'Edit', category: 'Access roles' as const, }, { key: 'accessRolesDelete' as const, label: 'Delete', category: 'Access roles' as const, deps: ['accessRolesCreate', 'accessRolesEdit'], }, { key: 'accessRolesAssign' as const, label: 'Assign', category: 'Access roles' as const, }, { key: 'sessionsView' as const, label: 'View', category: 'Sessions' as const, }, { key: 'sessionsTerminate' as const, label: 'Terminate', category: 'Sessions' as const, deps: ['sessionsView'], }, { key: 'recordingsView' as const, label: 'View', category: 'Recordings' as const, }, { key: 'ticketsCreate' as const, label: 'Create', category: 'Tickets' as const, }, { key: 'ticketsDelete' as const, label: 'Delete', category: 'Tickets' as const, deps: ['ticketsCreate'], }, { key: 'configEdit' as const, label: 'Edit configuration', category: 'Configuration' as const, }, { key: 'adminRolesManage' as const, label: 'Manage admin roles', category: 'Configuration', dangerous: true } as const, ] // eslint-disable-next-line @typescript-eslint/no-type-alias export type AdminPermission = typeof ADMIN_PERMISSIONS[number] // eslint-disable-next-line @typescript-eslint/no-type-alias export type AdminPermissionKey = AdminPermission['key'] // eslint-disable-next-line @typescript-eslint/no-type-alias export type AdminPermissionCategory = AdminPermission['category'] export function emptyPermissions(): AdminPermissions { return ADMIN_PERMISSIONS.reduce( (acc, { key }) => ({ ...acc, [key]: false }), {} as unknown as AdminPermissions, ) } export const adminPermissions = derived(serverInfo, $serverInfo => { return $serverInfo?.adminPermissions ?? emptyPermissions() }) export const hasAdminAccess = derived(adminPermissions, $adminPermissions => { return Object.values($adminPermissions).some(v => v) }) ================================================ FILE: warpgate-web/src/admin/lib/time.ts ================================================ import { formatDistanceToNow } from 'date-fns' export function timeAgo(t: Date): string { return formatDistanceToNow(t, { addSuffix: true }) } ================================================ FILE: warpgate-web/src/admin/player/TerminalRecordingPlayer.svelte ================================================
{#if loading} {/if} {#if !loading && !playing}
{/if}
{ formatDuration(timestamp * 1000, { leading: true }) }
{#if sessionIsLive === true} {/if} seek(duration * seekInputValue / 100)} />
================================================ FILE: warpgate-web/src/common/AsyncButton.svelte ================================================ ================================================ FILE: warpgate-web/src/common/AuthBar.svelte ================================================ {#if $serverInfo?.username} {$serverInfo.username} {#if $serverInfo.authorizedViaTicket} (ticket auth) {/if} {#if $serverInfo?.authorizedViaSsoWithSingleLogout} Log out of Warpgate Log out everywhere {:else} {/if} {/if} ================================================ FILE: warpgate-web/src/common/Brand.svelte ================================================
{@html logo}
================================================ FILE: warpgate-web/src/common/ConnectionInstructions.svelte ================================================ {#if targetKind === TargetKind.Ssh} {/if} {#if targetKind === TargetKind.Http} {#if ticketSecret} Alternatively, set the Authorization header when accessing the URL: {/if} {/if} {#if targetKind === TargetKind.MySql} Make sure you've set your client to require TLS and allowed cleartext password authentication. {/if} {#if targetKind === TargetKind.Postgres} Make sure you've set your client to require TLS and allowed cleartext password authentication. {/if} {#if targetKind === TargetKind.Kubernetes} Save the kubeconfig above to a file (e.g., warpgate-kubeconfig.yaml) and use it with kubectl. {#if !ticketSecret} You'll need to replace the placeholder certificate and key data with your actual credentials. {/if} {/if} ================================================ FILE: warpgate-web/src/common/CopyButton.svelte ================================================ {#if link} { _click() e.preventDefault() }} bind:this={button} > {#if children}{@render children()}{:else} {#if successVisible} Copied {:else} Copy {/if} {/if} {:else} {/if} ================================================ FILE: warpgate-web/src/common/CredentialUsedStateBadge.svelte ================================================ {#if credential.lastUsed} {#if credential.lastUsed.getTime() < lastUseThreshold.getTime()} Not used recently {:else} Used recently {/if} {:else} Never used {/if} {#if credential.dateAdded || credential.lastUsed} {#if credential.dateAdded}
Added on: {new Date(credential.dateAdded).toLocaleString()}
{/if} {#if credential.lastUsed}
Last used: {new Date(credential.lastUsed).toLocaleString()}
{/if}
{/if} ================================================ FILE: warpgate-web/src/common/DelayedSpinner.svelte ================================================ {#if visible} {/if} ================================================ FILE: warpgate-web/src/common/EmptyState.svelte ================================================

{title}

{#if hint}

{hint}

{/if}
================================================ FILE: warpgate-web/src/common/GettingStarted.svelte ================================================ ================================================ FILE: warpgate-web/src/common/GroupColorCircle.svelte ================================================
================================================ FILE: warpgate-web/src/common/InfoBox.svelte ================================================
{@render children()}
================================================ FILE: warpgate-web/src/common/ItemList.svelte ================================================ {#await $items} {:then _items} {#if _items}
{#each _items as _item, _index (_item)} {#if groupHeader} {#if _index === 0 || groupKey!(groupObject!(_item)) !== groupKey!(groupObject!(_items[_index - 1]!))} {@render groupHeader(groupObject!(_item))} {/if} {/if} {@render item?.(_item)} {/each}
{@render footer?.(_items)} {:else} {/if} {#if loaded && !_items?.length} {#if filter} {:else} {@render empty?.()} {/if} {/if} {/await} {#await $total then _total} {#if pageSize && _total > pageSize} {/if} {/await} ================================================ FILE: warpgate-web/src/common/Loadable.svelte ================================================ {#if !loaded} {:else} {#if !error} {@render children?.(data!)} {:else} {error} {/if} {/if} ================================================ FILE: warpgate-web/src/common/NavListItem.svelte ================================================
{#if titleSnippet} {@render titleSnippet()} {:else} {title} {/if}
{#if descriptionSnippet} {@render descriptionSnippet()} {:else if description} {description} {/if}
{@render addonSnippet?.()}
================================================ FILE: warpgate-web/src/common/Pagination.svelte ================================================ page--} href="#"> {#each pages as i (i)} {#if i !== null} page = i} href="#">{i + 1} {:else} ... {/if} {/each} = total}> page++} href="#"> ================================================ FILE: warpgate-web/src/common/RadioButton.svelte ================================================ ================================================ FILE: warpgate-web/src/common/RateLimitInput.svelte ================================================ {#each units as unit (unit.value)} {/each} ================================================ FILE: warpgate-web/src/common/ThemeSwitcher.svelte ================================================ {#if $currentTheme === 'dark'} Dark theme {:else if $currentTheme === 'light'} Light theme {:else} Automatic theme {/if} ================================================ FILE: warpgate-web/src/common/autosave.ts ================================================ import { BehaviorSubject } from 'rxjs' import { get, writable, type Writable } from 'svelte/store' export function autosave (key: string, initial: T): ([Writable, BehaviorSubject]) { key = `warpgate:${key}` const v = writable(JSON.parse(localStorage.getItem(key) ?? JSON.stringify(initial))) const v$ = new BehaviorSubject(get(v)) v.subscribe(value => { localStorage.setItem(key, JSON.stringify(value)) v$.next(value) }) return [v, v$] } ================================================ FILE: warpgate-web/src/common/errors.ts ================================================ import * as admin from 'admin/lib/api' import * as gw from 'gateway/lib/api' // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export async function stringifyError (err: any): Promise { if (err instanceof gw.ResponseError) { return gw.stringifyError(err) } if (err instanceof admin.ResponseError) { return admin.stringifyError(err) } return err.toString() } ================================================ FILE: warpgate-web/src/common/helpers.ts ================================================ import { type BootstrapThemeColor } from 'gateway/lib/api' export function getCSSColorFromThemeColor(color?: BootstrapThemeColor): string { // Handle capitalized color names from API (e.g., "Primary" -> "primary") const colorLower = (color ?? 'Secondary').toLowerCase() return `var(--bs-${colorLower});` } ================================================ FILE: warpgate-web/src/common/protocols.ts ================================================ import { shellEscape } from 'gateway/lib/shellEscape' import type { Info } from 'gateway/lib/api' import { CredentialKind } from 'admin/lib/api' export interface ConnectionOptions { targetName?: string username?: string serverInfo?: Info targetExternalHost?: string ticketSecret?: string targetDefaultDatabaseName?: string } export function makeSSHUsername (opt: ConnectionOptions): string { if (opt.ticketSecret) { return `ticket-${opt.ticketSecret}` } return `${opt.username ?? 'username'}:${opt.targetName ?? 'target'}` } export function makeExampleSSHCommand (opt: ConnectionOptions): string { return shellEscape([ 'ssh', `${makeSSHUsername(opt)}@${opt.serverInfo?.externalHost ?? 'warpgate-host'}`, '-p', (opt.serverInfo?.ports.ssh ?? 'warpgate-ssh-port').toString(), ]) } export function makeExampleSCPCommand (opt: ConnectionOptions): string { return shellEscape([ 'scp', '-o', `User="${makeSSHUsername(opt)}"`, '-P', (opt.serverInfo?.ports.ssh ?? 'warpgate-ssh-port').toString(), 'local-file', `${opt.serverInfo?.externalHost ?? 'warpgate-host'}:remote-file`, ]) } export function makeMySQLUsername (opt: ConnectionOptions): string { if (opt.ticketSecret) { return `ticket-${opt.ticketSecret}` } return `${opt.username ?? 'username'}#${opt.targetName ?? 'target'}` } export function makeExampleMySQLCommand (opt: ConnectionOptions): string { const dbName = opt.targetDefaultDatabaseName?.trim() || 'database-name' let cmd = shellEscape(['mysql', '-u', makeMySQLUsername(opt), '--host', opt.serverInfo?.externalHost ?? 'warpgate-host', '--port', (opt.serverInfo?.ports.mysql ?? 'warpgate-mysql-port').toString(), '--ssl', dbName]) if (!opt.ticketSecret) { cmd += ' -p' } return cmd } export function makeExampleMySQLURI (opt: ConnectionOptions): string { const pwSuffix = opt.ticketSecret ? '' : ':' const dbName = opt.targetDefaultDatabaseName?.trim() || 'database-name' return `mysql://${makeMySQLUsername(opt)}${pwSuffix}@${opt.serverInfo?.externalHost ?? 'warpgate-host'}:${opt.serverInfo?.ports.mysql ?? 'warpgate-mysql-port'}/${dbName}?sslMode=required` } export const makePostgreSQLUsername = makeMySQLUsername export function makeExamplePostgreSQLCommand (opt: ConnectionOptions): string { const dbName = opt.targetDefaultDatabaseName?.trim() || 'database-name' const args = ['psql', '-U', makeMySQLUsername(opt), '--host', opt.serverInfo?.externalHost ?? 'warpgate-host', '--port', (opt.serverInfo?.ports.postgres ?? 'warpgate-postgres-port').toString()] if (!opt.ticketSecret) { args.push('-W') } args.push(dbName) return shellEscape(args) } export function makeExamplePostgreSQLURI (opt: ConnectionOptions): string { const pwSuffix = opt.ticketSecret ? '' : ':' const dbName = opt.targetDefaultDatabaseName?.trim() || 'database-name' return `postgresql://${makePostgreSQLUsername(opt)}${pwSuffix}@${opt.serverInfo?.externalHost ?? 'warpgate-host'}:${opt.serverInfo?.ports.postgres ?? 'warpgate-postgres-port'}/${dbName}?sslmode=require` } export function makeTargetURL (opt: ConnectionOptions): string { const host = opt.targetExternalHost ? `${opt.targetExternalHost}:${opt.serverInfo?.ports.http ?? 443}` : location.host if (opt.ticketSecret) { return `${location.protocol}//${host}/?warpgate-ticket=${opt.ticketSecret}` } return `${location.protocol}//${host}/?warpgate-target=${opt.targetName}` } export const possibleCredentials: Record> = { ssh: new Set([CredentialKind.Password, CredentialKind.PublicKey, CredentialKind.Totp, CredentialKind.WebUserApproval]), http: new Set([CredentialKind.Password, CredentialKind.Totp, CredentialKind.Sso]), mysql: new Set([CredentialKind.Password]), postgres: new Set([CredentialKind.Password, CredentialKind.WebUserApproval]), kubernetes: new Set([CredentialKind.Certificate, CredentialKind.WebUserApproval]), } export function abbreviatePublicKey (key: string): string { return key.slice(0, 16) + '...' + key.slice(-8) } export function makeKubernetesContext (opt: ConnectionOptions): string { if (opt.ticketSecret) { return `ticket-${opt.ticketSecret}` } return `${opt.username ?? 'username'}:${opt.targetName ?? 'target'}` } export function makeKubernetesNamespace (_opt: ConnectionOptions): string { return 'default' } export function makeKubernetesClusterUrl (opt: ConnectionOptions): string { const baseUrl = `https://${opt.serverInfo?.externalHost ?? 'warpgate-host'}:${opt.serverInfo?.ports.kubernetes ?? 'warpgate-kubernetes-port'}` return `${baseUrl}/${encodeURIComponent(opt.targetName ?? 'target')}` } export function makeKubeconfig (opt: ConnectionOptions): string { const clusterUrl = makeKubernetesClusterUrl(opt) const context = makeKubernetesContext(opt) const namespace = makeKubernetesNamespace(opt) if (opt.ticketSecret) { // Token-based authentication using API ticket return `apiVersion: v1 kind: Config clusters: - cluster: server: ${clusterUrl} insecure-skip-tls-verify: true name: warpgate-${opt.targetName ?? 'target'} contexts: - context: cluster: warpgate-${opt.targetName ?? 'target'} namespace: ${namespace} user: ${context} name: ${context} current-context: ${context} users: - name: ${context} user: token: ${opt.ticketSecret} ` } else { // Certificate-based authentication return `apiVersion: v1 kind: Config clusters: - cluster: server: ${clusterUrl} insecure-skip-tls-verify: true name: warpgate-${opt.targetName ?? 'target'} contexts: - context: cluster: warpgate-${opt.targetName ?? 'target'} namespace: ${namespace} user: ${context} name: ${context} current-context: ${context} users: - name: ${context} user: client-certificate-data: client-key-data: ` } } export function makeExampleKubectlCommand (_opt: ConnectionOptions): string { return shellEscape(['kubectl', '--kubeconfig', 'warpgate-kubeconfig.yaml', 'get', 'pods']) } export interface ProtocolProperties { sessionsCanBeClosed: boolean } export const PROTOCOL_PROPERTIES: Record = { ssh: { sessionsCanBeClosed: true }, http: { sessionsCanBeClosed: true }, mysql: { sessionsCanBeClosed: true }, postgres: { sessionsCanBeClosed: true }, kubernetes: { sessionsCanBeClosed: false }, } ================================================ FILE: warpgate-web/src/common/recordings.ts ================================================ import { type Recording } from 'admin/lib/api' export type RecordingMetadata ={ type: 'kubernetes-exec', namespace: string pod: string container: string command: string } | { type: 'kubernetes-attach', namespace: string pod: string container: string } | { type: 'kubernetes-api', } | { type: 'ssh-shell', channel: number } | { type: 'ssh-exec', channel: number } | { type: 'ssh-direct-tcpip', host: string port: number } | { type: 'ssh-direct-socket', path: string } | { type: 'ssh-forwarded-tcpip', host: string port: number } | { type: 'ssh-forwarded-socket', path: string } export function recordingMetadataToFieldSet(metadata: RecordingMetadata): [string, string][] { const fieldSets: [string, string][] = [] switch (metadata.type) { case 'kubernetes-exec': fieldSets.push(['Namespace', metadata.namespace]) fieldSets.push(['Pod', metadata.pod]) fieldSets.push(['Container', metadata.container]) fieldSets.push(['Command', metadata.command]) break case 'kubernetes-attach': fieldSets.push(['Namespace', metadata.namespace]) fieldSets.push(['Pod', metadata.pod]) fieldSets.push(['Container', metadata.container]) break case 'ssh-shell': fieldSets.push(['Channel', metadata.channel.toString()]) break case 'ssh-exec': fieldSets.push(['Channel', metadata.channel.toString()]) break case 'ssh-direct-tcpip': case 'ssh-forwarded-tcpip': fieldSets.push(['Host', metadata.host]) fieldSets.push(['Port', metadata.port.toString()]) break case 'ssh-direct-socket': case 'ssh-forwarded-socket': fieldSets.push(['Path', metadata.path]) break } return fieldSets } export function recordingTypeLabel(recording: Recording): string { const metadata = JSON.parse(recording.metadata) as RecordingMetadata | null switch (metadata?.type) { case 'kubernetes-api': return 'API' case 'kubernetes-exec': return 'Exec' case 'kubernetes-attach': return 'Attach' case 'ssh-shell': return 'Shell' case 'ssh-exec': return 'Exec' case 'ssh-direct-tcpip': return 'Local TCP forwarding' case 'ssh-direct-socket': return 'Local UNIX socket forwarding' case 'ssh-forwarded-tcpip': return 'Remote TCP forwarding' case 'ssh-forwarded-socket': return 'Remote UNIX socket forwarding' } return 'Unknown type' } ================================================ FILE: warpgate-web/src/common/sveltestrap-s5-ports/Alert.svelte ================================================ {#if isOpen} {/if} ================================================ FILE: warpgate-web/src/common/sveltestrap-s5-ports/Badge.svelte ================================================ {#if href} {@render children?.()} {#if positioned || indicator} {ariaLabel} {/if} {:else} {@render children?.()} {#if positioned || indicator} {ariaLabel} {/if} {/if} ================================================ FILE: warpgate-web/src/common/sveltestrap-s5-ports/ModalHeader.svelte ================================================
{#if close}{@render close()}{:else} {#if typeof toggle === 'function'} {/if} {/if}
================================================ FILE: warpgate-web/src/common/sveltestrap-s5-ports/Tooltip.svelte ================================================ {#if isOpen} {@const SvelteComponent = outer} {/if} ================================================ FILE: warpgate-web/src/common/sveltestrap-s5-ports/_sveltestrapUtils.ts ================================================ // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export function toClassName(value: any) { let result = '' if (typeof value === 'string' || typeof value === 'number') { result += value } else if (typeof value === 'object') { if (Array.isArray(value)) { result = value.map(toClassName).filter(Boolean).join(' ') } else { for (const key in value) { if (value[key]) { // eslint-disable-next-line @typescript-eslint/no-unused-expressions result && (result += ' ') result += key } } } } return result } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export const classnames = (...args: any[]) => args.map(toClassName).filter(Boolean).join(' ') export function uuid(): string { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0 const v = c === 'x' ? r : (r & 0x3) | 0x8 return v.toString(16) }) } ================================================ FILE: warpgate-web/src/embed/EmbeddedUI.svelte ================================================ { menuVisible = false stopDragging() }} />
Warpgate { if (!dragging) { menuVisible = !menuVisible } else { stopDragging() } }} on:mousedown|preventDefault on:mousemove|preventDefault={e => { if (e.buttons && !dragging) { startDragging(e) } }} > {#if menuVisible} {/if}
================================================ FILE: warpgate-web/src/embed/index.ts ================================================ import { api } from 'gateway/lib/api' import EmbeddedUI from './EmbeddedUI.svelte' export { } navigator.serviceWorker.getRegistrations().then(registrations => { for (const registration of registrations) { registration.unregister() } }) api.getInfo().then(info => { console.log(`Warpgate v${info.version}, logged in as ${info.username}`) }) const container = document.createElement('div') container.id = 'warpgate-embedded-ui' document.body.appendChild(container) setTimeout(() => new EmbeddedUI({ target: container, })) ================================================ FILE: warpgate-web/src/gateway/ApiTokenManager.svelte ================================================

API tokens

{#if lastCreatedSecret}
Your token - shown only once:
{lastCreatedSecret}
{/if} {#if tokens.length === 0} {/if}
{#each tokens as token (token.id)}
{token.label} {#if token.expiry.getTime() < now} Expired {:else} {token.expiry.toLocaleDateString()} {/if}
{/each}
{#if creatingToken} {/if} ================================================ FILE: warpgate-web/src/gateway/App.svelte ================================================
{#if redirecting} {:else}
{#if $hasAdminAccess} Admin {/if}
{#if !doNotShowAuthRequests} {#each webAuthRequests as authRequest (authRequest.id)} {/each} {/if}
{ doNotShowAuthRequests = !!(e.detail.userData as any)?.['doNotShowAuthRequests'] }} />
{#if $serverInfo?.version} {$serverInfo.version} {:else}
{/if}
{/if}
================================================ FILE: warpgate-web/src/gateway/CreateApiTokenModal.svelte ================================================ field?.focus()}>
{ _save() e.preventDefault() }}> New API token
================================================ FILE: warpgate-web/src/gateway/CredentialManager.svelte ================================================ {#if creds}

Password

{#if creds.password === PasswordState.Unset} Your account has no password set {/if} {#if creds.password === PasswordState.Set} Password set {/if} {#if creds.password === PasswordState.MultipleSet} Multiple passwords set {/if}
{#if creds.publicKeys.length === 0 && Object.values(creds.credentialPolicy).some(l => l?.includes(CredentialKind.Password))} Your credential policy requires using a password for authentication. Without one, you won't be able to log in. {/if}

One-time passwords

{#each creds.otp as credential (credential.id)}
OTP device
{/each}
{#if creds.otp.length === 0 && Object.values(creds.credentialPolicy).some(l => l?.includes(CredentialKind.Totp))} Your credential policy requires using a one-time password for authentication. Without one, you won't be able to log in. {/if}

Public keys

Public key credentials will be loaded from LDAP
{#each creds.publicKeys as credential (credential.id)}
{credential.label}
{credential.abbreviated}
{/each}
{#if creds.publicKeys.length === 0 && creds.credentialPolicy.ssh?.includes(CredentialKind.PublicKey)} Your credential policy requires using a public key for authentication. Without one, you won't be able to log in. {/if}

Certificates

{#each creds.certificates as credential (credential.id)}
{credential.label}
SHA-256: {credential.fingerprint}
{/each}
{#if creds.certificates.length === 0 && creds.credentialPolicy.kubernetes?.includes(CredentialKind.Certificate)} Your credential policy requires using a certificate for authentication. Without one, you won't be able to log in. {/if} {#if creds.sso.length > 0}

Single sign-on

{#each creds.sso as credential (credential.id)}
{credential.email} {#if credential.provider} ({credential.provider}){/if}
{/each}
{/if} {/if}
{#if changingPassword} {/if} {#if creatingPublicKeyCredential} {/if} {#if creatingOtpCredential} {/if} {#if issuingCertificateCredential} { issuingCertificateCredential = false }} /> {/if} ================================================ FILE: warpgate-web/src/gateway/Login.svelte ================================================ {#snippet localLoginForm()}
{ login() e.preventDefault() }}>
{/snippet}
{#if authState === ApiAuthState.NotStarted || authState === ApiAuthState.Failed}

Welcome

{:else}

Continue login

{/if}
{#if authState === ApiAuthState.OtpNeeded}
{ login() e.preventDefault() }}>
{/if} {#if (authState === ApiAuthState.NotStarted || authState === ApiAuthState.PasswordNeeded || authState === ApiAuthState.Failed) && (!$serverInfo?.minimizePasswordLogin || showPasswordLogin)} {@render localLoginForm()} {/if}
{#if authState === ApiAuthState.Failed} Incorrect credentials {/if} {#if serverErrorMessage} {serverErrorMessage} {/if} {#if error} {error} {/if}
{#if authState === ApiAuthState.SsoNeeded || authState === ApiAuthState.NotStarted || authState === ApiAuthState.Failed} {#snippet children(ssoProviders)}
{#each ssoProviders as ssoProvider (ssoProvider.name)} {/each}
{/snippet}
{/if} {#if (authState === ApiAuthState.NotStarted || authState === ApiAuthState.PasswordNeeded || authState === ApiAuthState.Failed) && $serverInfo?.minimizePasswordLogin && !showPasswordLogin} {/if} {#if authState !== ApiAuthState.NotStarted && authState !== ApiAuthState.Failed} {/if}
================================================ FILE: warpgate-web/src/gateway/OutOfBandAuth.svelte ================================================ {#if authState}

authorization request

Ensure this security key matches your authentication prompt:
{#each authState?.identificationString as char}
{char}
{/each}
Authorize this {authState.protocol} session?
Requested {#if authState.address}from {authState.address}{/if}
{#if authState.state === ApiAuthState.Success} Approved {:else if authState.state === ApiAuthState.Failed} Rejected {:else}
Authorize Reject
{/if} {/if}
================================================ FILE: warpgate-web/src/gateway/Profile.svelte ================================================

{$serverInfo!.username}

{#if $serverInfo} {#if $serverInfo.ownCredentialManagementAllowed} {/if} {/if} ================================================ FILE: warpgate-web/src/gateway/ProfileApiTokens.svelte ================================================ Pass the token in the X-Warpgate-Token header ================================================ FILE: warpgate-web/src/gateway/ProfileCredentials.svelte ================================================

Credentials

{#if $serverInfo} {#if $serverInfo.ownCredentialManagementAllowed} {:else} Credential management is disabled by your administrator {/if} {/if} ================================================ FILE: warpgate-web/src/gateway/TargetList.svelte ================================================ {#if $serverInfo?.setupState} {/if} group.id}> {#snippet empty()} {/snippet} {#snippet groupHeader(group)}
{group.name}
{/snippet} {#snippet item(target)} { if (e.metaKey || e.ctrlKey) { return } e.preventDefault() selectTarget(target) }} >
{target.name}
{#if target.description} {target.description} {/if}
{#if target.kind === TargetKind.Ssh} SSH {/if} {#if target.kind === TargetKind.MySql} MySQL {/if} {#if target.kind === TargetKind.Postgres} PostgreSQL {/if} {#if target.kind === TargetKind.Kubernetes} Kubernetes {/if} {#if target.kind === TargetKind.Http} {/if}
{/snippet}
{#if $serverInfo?.setupState && !$serverInfo.setupState.hasTargets} {/if} selectedTarget = undefined}> {#if selectedTarget} {/if} ================================================ FILE: warpgate-web/src/gateway/index.html ================================================ Warpgate
================================================ FILE: warpgate-web/src/gateway/index.ts ================================================ import { mount } from 'svelte' import '../theme' import App from './App.svelte' mount(App, { target: document.getElementById('app')!, }) export { } ================================================ FILE: warpgate-web/src/gateway/lib/api.ts ================================================ import { DefaultApi, Configuration, ResponseError } from './api-client' const configuration = new Configuration({ basePath: '/@warpgate/api', }) export const api = new DefaultApi(configuration) export * from './api-client' export async function stringifyError (err: ResponseError): Promise { return `API error: ${await err.response.text()}` } ================================================ FILE: warpgate-web/src/gateway/lib/openapi-schema.json ================================================ { "openapi": "3.0.0", "info": { "title": "Warpgate HTTP proxy", "version": "v0.21.1-5-ga76ef2bb-modified" }, "servers": [ { "url": "/@warpgate/api" } ], "tags": [], "paths": { "/auth/login": { "post": { "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/LoginRequest" } } }, "required": true }, "responses": { "201": { "description": "" }, "401": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/LoginFailureResponse" } } } } }, "operationId": "login" } }, "/auth/otp": { "post": { "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/OtpLoginRequest" } } }, "required": true }, "responses": { "201": { "description": "" }, "401": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/LoginFailureResponse" } } } } }, "operationId": "otpLogin" } }, "/auth/logout": { "post": { "responses": { "201": { "description": "" } }, "operationId": "logout" } }, "/auth/state": { "get": { "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/AuthStateResponseInternal" } } } }, "404": { "description": "" } }, "operationId": "get_default_auth_state" }, "delete": { "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/AuthStateResponseInternal" } } } }, "404": { "description": "" } }, "operationId": "cancel_default_auth" } }, "/auth/web-auth-requests": { "get": { "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/AuthStateResponseInternal" } } } } }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_web_auth_requests" } }, "/auth/state/{id}": { "get": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/AuthStateResponseInternal" } } } }, "404": { "description": "" } }, "operationId": "get_auth_state" } }, "/auth/state/{id}/approve": { "post": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/AuthStateResponseInternal" } } } }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "approve_auth" } }, "/auth/state/{id}/reject": { "post": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/AuthStateResponseInternal" } } } }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "reject_auth" } }, "/info": { "get": { "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/Info" } } } } }, "operationId": "get_info" } }, "/targets": { "get": { "parameters": [ { "name": "search", "schema": { "type": "string" }, "in": "query", "required": false, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/TargetSnapshot" } } } } } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_targets" } }, "/sso/providers": { "get": { "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/SsoProviderDescription" } } } } } }, "operationId": "get_sso_providers" } }, "/sso/return": { "get": { "parameters": [ { "name": "code", "schema": { "type": "string" }, "in": "query", "required": false, "deprecated": false, "explode": true } ], "responses": { "307": { "description": "" } }, "operationId": "return_to_sso" }, "post": { "responses": { "200": { "description": "", "content": { "text/html; charset=utf-8": { "schema": { "type": "string" } } } } }, "operationId": "return_to_sso_with_form_data" } }, "/sso/logout": { "get": { "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/StartSloResponseParams" } } } }, "400": { "description": "" }, "404": { "description": "" } }, "operationId": "initiate_sso_logout" } }, "/sso/providers/{name}/start": { "get": { "parameters": [ { "name": "name", "schema": { "type": "string" }, "in": "path", "required": true, "deprecated": false, "explode": true }, { "name": "next", "schema": { "type": "string" }, "in": "query", "required": false, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/StartSsoResponseParams" } } } }, "404": { "description": "" } }, "operationId": "start_sso" } }, "/profile/credentials": { "get": { "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/CredentialsState" } } } }, "401": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "get_my_credentials" } }, "/profile/credentials/password": { "post": { "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/ChangePasswordRequest" } } }, "required": true }, "responses": { "201": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/PasswordState" } } } }, "401": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "change_my_password" } }, "/profile/credentials/public-keys": { "post": { "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/NewPublicKeyCredential" } } }, "required": true }, "responses": { "201": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/ExistingPublicKeyCredential" } } } }, "401": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "add_my_public_key" } }, "/profile/credentials/public-keys/{id}": { "delete": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "204": { "description": "" }, "401": { "description": "" }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "delete_my_public_key" } }, "/profile/credentials/otp": { "post": { "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/NewOtpCredential" } } }, "required": true }, "responses": { "201": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/ExistingOtpCredential" } } } }, "401": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "add_my_otp" } }, "/profile/credentials/otp/{id}": { "delete": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "204": { "description": "" }, "401": { "description": "" }, "404": { "description": "" } }, "security": [ { "TokenSecurityScheme": [] }, { "CookieSecurityScheme": [] } ], "operationId": "delete_my_otp" } }, "/profile/credentials/certificates": { "post": { "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/IssueCertificateCredentialRequest" } } }, "required": true }, "responses": { "201": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/IssuedCertificateCredential" } } } }, "401": { "description": "" } }, "operationId": "issue_my_certificate" } }, "/profile/credentials/certificates/{id}": { "delete": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "200": { "description": "" }, "401": { "description": "" }, "404": { "description": "" } }, "operationId": "revoke_my_certificate" } }, "/profile/api-tokens": { "get": { "responses": { "200": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ExistingApiToken" } } } } }, "401": { "description": "" } }, "operationId": "get_my_api_tokens" }, "post": { "requestBody": { "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/NewApiToken" } } }, "required": true }, "responses": { "201": { "description": "", "content": { "application/json; charset=utf-8": { "schema": { "$ref": "#/components/schemas/TokenAndSecret" } } } }, "401": { "description": "" } }, "operationId": "create_api_token" } }, "/profile/api-tokens/{id}": { "delete": { "parameters": [ { "name": "id", "schema": { "type": "string", "format": "uuid" }, "in": "path", "required": true, "deprecated": false, "explode": true } ], "responses": { "204": { "description": "" }, "401": { "description": "" }, "404": { "description": "" } }, "operationId": "delete_my_api_token" } } }, "components": { "schemas": { "AdminPermissions": { "type": "object", "title": "AdminPermissions", "required": [ "targets_create", "targets_edit", "targets_delete", "users_create", "users_edit", "users_delete", "access_roles_create", "access_roles_edit", "access_roles_delete", "access_roles_assign", "sessions_view", "sessions_terminate", "recordings_view", "tickets_create", "tickets_delete", "config_edit", "admin_roles_manage" ], "properties": { "targets_create": { "type": "boolean" }, "targets_edit": { "type": "boolean" }, "targets_delete": { "type": "boolean" }, "users_create": { "type": "boolean" }, "users_edit": { "type": "boolean" }, "users_delete": { "type": "boolean" }, "access_roles_create": { "type": "boolean" }, "access_roles_edit": { "type": "boolean" }, "access_roles_delete": { "type": "boolean" }, "access_roles_assign": { "type": "boolean" }, "sessions_view": { "type": "boolean" }, "sessions_terminate": { "type": "boolean" }, "recordings_view": { "type": "boolean" }, "tickets_create": { "type": "boolean" }, "tickets_delete": { "type": "boolean" }, "config_edit": { "type": "boolean" }, "admin_roles_manage": { "type": "boolean" } } }, "ApiAuthState": { "type": "string", "enum": [ "NotStarted", "Failed", "PasswordNeeded", "OtpNeeded", "SsoNeeded", "WebUserApprovalNeeded", "PublicKeyNeeded", "Success" ] }, "AuthStateResponseInternal": { "type": "object", "title": "AuthStateResponseInternal", "required": [ "id", "protocol", "started", "state", "identification_string" ], "properties": { "id": { "type": "string" }, "protocol": { "type": "string" }, "address": { "type": "string" }, "started": { "type": "string", "format": "date-time" }, "state": { "$ref": "#/components/schemas/ApiAuthState" }, "identification_string": { "type": "string" } } }, "BootstrapThemeColor": { "type": "string", "enum": [ "Primary", "Secondary", "Success", "Danger", "Warning", "Info", "Light", "Dark" ] }, "ChangePasswordRequest": { "type": "object", "title": "ChangePasswordRequest", "required": [ "password" ], "properties": { "password": { "type": "string" } } }, "CredentialKind": { "type": "string", "enum": [ "Password", "PublicKey", "Certificate", "Totp", "Sso", "WebUserApproval" ] }, "CredentialsState": { "type": "object", "title": "CredentialsState", "required": [ "password", "otp", "public_keys", "certificates", "sso", "credential_policy", "ldap_linked" ], "properties": { "password": { "$ref": "#/components/schemas/PasswordState" }, "otp": { "type": "array", "items": { "$ref": "#/components/schemas/ExistingOtpCredential" } }, "public_keys": { "type": "array", "items": { "$ref": "#/components/schemas/ExistingPublicKeyCredential" } }, "certificates": { "type": "array", "items": { "$ref": "#/components/schemas/ExistingCertificateCredential" } }, "sso": { "type": "array", "items": { "$ref": "#/components/schemas/ExistingSsoCredential" } }, "credential_policy": { "$ref": "#/components/schemas/UserRequireCredentialsPolicy" }, "ldap_linked": { "type": "boolean" } } }, "ExistingApiToken": { "type": "object", "title": "ExistingApiToken", "required": [ "id", "label", "created", "expiry" ], "properties": { "id": { "type": "string", "format": "uuid" }, "label": { "type": "string" }, "created": { "type": "string", "format": "date-time" }, "expiry": { "type": "string", "format": "date-time" } } }, "ExistingCertificateCredential": { "type": "object", "title": "ExistingCertificateCredential", "required": [ "id", "label", "fingerprint" ], "properties": { "id": { "type": "string", "format": "uuid" }, "label": { "type": "string" }, "date_added": { "type": "string", "format": "date-time" }, "last_used": { "type": "string", "format": "date-time" }, "fingerprint": { "type": "string" } } }, "ExistingOtpCredential": { "type": "object", "title": "ExistingOtpCredential", "required": [ "id" ], "properties": { "id": { "type": "string", "format": "uuid" } } }, "ExistingPublicKeyCredential": { "type": "object", "title": "ExistingPublicKeyCredential", "required": [ "id", "label", "abbreviated" ], "properties": { "id": { "type": "string", "format": "uuid" }, "label": { "type": "string" }, "date_added": { "type": "string", "format": "date-time" }, "last_used": { "type": "string", "format": "date-time" }, "abbreviated": { "type": "string" } } }, "ExistingSsoCredential": { "type": "object", "title": "ExistingSsoCredential", "required": [ "id", "email" ], "properties": { "id": { "type": "string", "format": "uuid" }, "provider": { "type": "string" }, "email": { "type": "string" } } }, "GroupInfo": { "type": "object", "title": "GroupInfo", "required": [ "id", "name" ], "properties": { "id": { "type": "string", "format": "uuid" }, "name": { "type": "string" }, "color": { "$ref": "#/components/schemas/BootstrapThemeColor" } } }, "Info": { "type": "object", "title": "Info", "required": [ "ports", "minimize_password_login", "authorized_via_ticket", "authorized_via_sso_with_single_logout", "own_credential_management_allowed", "has_ldap" ], "properties": { "version": { "type": "string" }, "username": { "type": "string" }, "selected_target": { "type": "string" }, "external_host": { "type": "string" }, "ports": { "$ref": "#/components/schemas/PortsInfo" }, "minimize_password_login": { "type": "boolean" }, "authorized_via_ticket": { "type": "boolean" }, "authorized_via_sso_with_single_logout": { "type": "boolean" }, "own_credential_management_allowed": { "type": "boolean" }, "has_ldap": { "type": "boolean" }, "setup_state": { "$ref": "#/components/schemas/SetupState" }, "admin_permissions": { "$ref": "#/components/schemas/AdminPermissions" } } }, "IssueCertificateCredentialRequest": { "type": "object", "title": "IssueCertificateCredentialRequest", "required": [ "label", "public_key_pem" ], "properties": { "label": { "type": "string" }, "public_key_pem": { "type": "string" } } }, "IssuedCertificateCredential": { "type": "object", "title": "IssuedCertificateCredential", "required": [ "credential", "certificate_pem" ], "properties": { "credential": { "$ref": "#/components/schemas/ExistingCertificateCredential" }, "certificate_pem": { "type": "string" } } }, "LoginFailureResponse": { "type": "object", "title": "LoginFailureResponse", "required": [ "state" ], "properties": { "state": { "$ref": "#/components/schemas/ApiAuthState" } } }, "LoginRequest": { "type": "object", "title": "LoginRequest", "required": [ "username", "password" ], "properties": { "username": { "type": "string" }, "password": { "type": "string" } } }, "NewApiToken": { "type": "object", "title": "NewApiToken", "required": [ "label", "expiry" ], "properties": { "label": { "type": "string" }, "expiry": { "type": "string", "format": "date-time" } } }, "NewOtpCredential": { "type": "object", "title": "NewOtpCredential", "required": [ "secret_key" ], "properties": { "secret_key": { "type": "array", "items": { "type": "integer", "format": "uint8" } } } }, "NewPublicKeyCredential": { "type": "object", "title": "NewPublicKeyCredential", "required": [ "label", "openssh_public_key" ], "properties": { "label": { "type": "string" }, "openssh_public_key": { "type": "string" } } }, "OtpLoginRequest": { "type": "object", "title": "OtpLoginRequest", "required": [ "otp" ], "properties": { "otp": { "type": "string" } } }, "PasswordState": { "type": "string", "enum": [ "Unset", "Set", "MultipleSet" ] }, "PortsInfo": { "type": "object", "title": "PortsInfo", "properties": { "ssh": { "type": "integer", "format": "uint16" }, "http": { "type": "integer", "format": "uint16" }, "mysql": { "type": "integer", "format": "uint16" }, "postgres": { "type": "integer", "format": "uint16" }, "kubernetes": { "type": "integer", "format": "uint16" } } }, "SetupState": { "type": "object", "title": "SetupState", "required": [ "has_targets", "has_users" ], "properties": { "has_targets": { "type": "boolean" }, "has_users": { "type": "boolean" } } }, "SsoProviderDescription": { "type": "object", "title": "SsoProviderDescription", "required": [ "name", "label", "kind" ], "properties": { "name": { "type": "string" }, "label": { "type": "string" }, "kind": { "$ref": "#/components/schemas/SsoProviderKind" } } }, "SsoProviderKind": { "type": "string", "enum": [ "Google", "Apple", "Azure", "Custom" ] }, "StartSloResponseParams": { "type": "object", "title": "StartSloResponseParams", "required": [ "url" ], "properties": { "url": { "type": "string" } } }, "StartSsoResponseParams": { "type": "object", "title": "StartSsoResponseParams", "required": [ "url" ], "properties": { "url": { "type": "string" } } }, "TargetKind": { "type": "string", "enum": [ "Http", "Kubernetes", "MySql", "Ssh", "Postgres" ] }, "TargetSnapshot": { "type": "object", "title": "TargetSnapshot", "required": [ "name", "description", "kind" ], "properties": { "name": { "type": "string" }, "description": { "type": "string" }, "kind": { "$ref": "#/components/schemas/TargetKind" }, "external_host": { "type": "string" }, "group": { "$ref": "#/components/schemas/GroupInfo" }, "default_database_name": { "type": "string" } } }, "TokenAndSecret": { "type": "object", "title": "TokenAndSecret", "required": [ "token", "secret" ], "properties": { "token": { "$ref": "#/components/schemas/ExistingApiToken" }, "secret": { "type": "string" } } }, "UserRequireCredentialsPolicy": { "type": "object", "title": "UserRequireCredentialsPolicy", "properties": { "http": { "type": "array", "items": { "$ref": "#/components/schemas/CredentialKind" } }, "kubernetes": { "type": "array", "items": { "$ref": "#/components/schemas/CredentialKind" } }, "ssh": { "type": "array", "items": { "$ref": "#/components/schemas/CredentialKind" } }, "mysql": { "type": "array", "items": { "$ref": "#/components/schemas/CredentialKind" } }, "postgres": { "type": "array", "items": { "$ref": "#/components/schemas/CredentialKind" } } } } }, "securitySchemes": { "CookieSecurityScheme": { "type": "apiKey", "name": "warpgate-http-session", "in": "cookie" }, "TokenSecurityScheme": { "type": "apiKey", "name": "X-Warpgate-Token", "in": "header" } } } } ================================================ FILE: warpgate-web/src/gateway/lib/shellEscape.ts ================================================ import { UAParser } from 'ua-parser-js' function escapeUnix (arg: string): string { if (!/^[A-Za-z0-9_/-]+$/.test(arg)) { return ('\'' + arg.replace(/'/g, '\'"\'"\'') + '\'').replace(/''/g, '') } return arg } function escapeWin (arg: string): string { if (!/^[A-Za-z0-9_/-]+$/.test(arg)) { return '"' + arg.replace(/"/g, '""') + '"' } return arg } const isWin = new UAParser().getOS().name === 'Windows' export function shellEscape (stringOrArray: string[]|string): string { const ret: string[] = [] const escapePath = isWin ? escapeWin : escapeUnix if (typeof stringOrArray == 'string') { return escapePath(stringOrArray) } else { stringOrArray.forEach(function (member) { ret.push(escapePath(member)) }) return ret.join(' ') } } ================================================ FILE: warpgate-web/src/gateway/lib/store.ts ================================================ import { writable } from 'svelte/store' import { api, type Info } from './api' export const serverInfo = writable(undefined) export async function reloadServerInfo (): Promise { serverInfo.set(await api.getInfo()) } ================================================ FILE: warpgate-web/src/gateway/login.ts ================================================ import { mount } from 'svelte' import Login from './Login.svelte' const app = {} mount(Login, { target: document.getElementById('app')!, }) export default app ================================================ FILE: warpgate-web/src/lib.rs ================================================ use std::collections::HashMap; use rust_embed::RustEmbed; use serde::Deserialize; #[derive(RustEmbed)] #[folder = "../warpgate-web/dist"] pub struct Assets; #[derive(thiserror::Error, Debug)] pub enum LookupError { #[error("I/O")] Io(#[from] std::io::Error), #[error("Serde")] Serde(#[from] serde_json::Error), #[error("File not found in manifest")] FileNotFound, #[error("Manifest not found")] ManifestNotFound, } #[derive(Deserialize, Clone)] pub struct ManifestEntry { pub file: String, pub css: Option>, } pub fn lookup_built_file(source: &str) -> Result { let file = Assets::get(".vite/manifest.json").ok_or(LookupError::ManifestNotFound)?; let obj: HashMap = serde_json::from_slice(&file.data)?; obj.get(source).cloned().ok_or(LookupError::FileNotFound) } ================================================ FILE: warpgate-web/src/theme/_theme.scss ================================================ @use "sass:map"; @mixin button-variant( $background, $border, $color: color-contrast($background), $hover-background: if($color == $color-contrast-dark, shade-color($background, $btn-hover-bg-shade-amount), tint-color($background, $btn-hover-bg-tint-amount)), $hover-border: if($color == $color-contrast-dark, shade-color($border, $btn-hover-border-shade-amount), tint-color($border, $btn-hover-border-tint-amount)), $hover-color: color-contrast($hover-background), $active-background: if($color == $color-contrast-dark, shade-color($background, $btn-active-bg-shade-amount), tint-color($background, $btn-active-bg-tint-amount)), $active-border: if($color == $color-contrast-dark, shade-color($border, $btn-active-border-shade-amount), tint-color($border, $btn-active-border-tint-amount)), $active-color: color-contrast($active-background), $disabled-background: $background, $disabled-border: $border, $disabled-color: $btn-disabled-color, $text-color: if($color == $color-contrast-light, shade-color($background, $btn-color-shade-amount), tint-color($background, $btn-color-tint-amount)) ) { $real-background: if($color == $color-contrast-dark, shade-color($background, $btn-bg-shade-amount), tint-color($background, $btn-bg-tint-amount)); $real-color: if($color == $color-contrast-light, shade-color($background, $btn-color-shade-amount), tint-color($background, $btn-color-tint-amount)); --#{$prefix}btn-color: #{$real-color}; --#{$prefix}btn-bg: #{$real-background}; --#{$prefix}btn-border-color: #{if($color == $color-contrast-dark, shade-color($background, $btn-border-shade-amount), tint-color($background, $btn-border-tint-amount))}; --#{$prefix}btn-hover-color: #{$hover-color}; --#{$prefix}btn-hover-bg: #{$hover-background}; --#{$prefix}btn-hover-border-color: #{$hover-border}; --#{$prefix}btn-focus-shadow-rgb: #{to-rgb(mix($color, $border, 15%))}; --#{$prefix}btn-active-color: #{$active-color}; --#{$prefix}btn-active-bg: #{$active-background}; --#{$prefix}btn-active-border-color: #{$active-border}; --#{$prefix}btn-active-shadow: #{$btn-active-box-shadow}; --#{$prefix}btn-disabled-color: #{$disabled-color}; --#{$prefix}btn-disabled-bg: #{$real-background}; } // 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"; $font-family-os: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" !default; #app { display: block; -webkit-font-smoothing: antialiased; } .page-summary-bar { display: flex; align-items: center; margin: 0.25rem 0 1.5rem; gap: 1rem; h1 { font-family: 'Poppins'; font-weight: 700; font-size: 2.5rem; margin: 0; } .counter { display: inline-block; padding: 0px 10px; background: var(--bs-body-color); color: var(--bs-body-bg); border-radius: 5px; } } // .alert { // border-top-style: none; // border-bottom-style: none; // border-right-style: none; // border-left-width: 2px; // font-style: italic; // background: rgba(0,0,0,.25) !important; // padding: 0.7rem 1rem; // margin: 1rem 0; // } footer { display: flex; align-items: center; padding: .5rem 0; margin: 2rem 0 1rem; border-top: 1px solid rgba($body-color, .2); } input:-webkit-autofill { -webkit-background-clip: text; -webkit-box-shadow: 0 0 0 50px $input-bg inset; -webkit-text-fill-color: $input-color; } input:-webkit-autofill:focus { -webkit-background-clip: text; -webkit-box-shadow: 0 0 0 50px $input-focus-bg inset; -webkit-text-fill-color: $input-focus-color; } .badge { font-family: $font-family-os; } .btn { text-decoration: underline; text-decoration-color: var(--#{$prefix}btn-active-bg); } .page-item.active .page-link { text-decoration: underline; } // Fix placeholder text with floating FormGroup labels .form-floating>.form-control::placeholder { color: revert; } .form-floating>.form-control:not(:focus)::placeholder { color: transparent; } @include media-breakpoint-up(md) { .narrow-page { width: map.get($grid-breakpoints, "md"); } } a { text-decoration-color: var(--wg-link-underline-color); text-underline-offset: 2px; &:hover, &.active { text-decoration-color: var(--wg-link-hover-underline-color); } } // Make these vars reusable from everywhere body { --bs-list-group-color: #{$list-group-color}; --bs-list-group-bg: #{$list-group-bg}; --bs-list-group-border-color: #{$list-group-border-color}; --bs-list-group-border-width: #{$list-group-border-width}; --bs-list-group-border-radius: #{$list-group-border-radius}; --bs-list-group-item-padding-x: #{$list-group-item-padding-x}; --bs-list-group-item-padding-y: #{$list-group-item-padding-y}; --bs-list-group-action-color: #{$list-group-action-color}; --bs-list-group-action-hover-color: #{$list-group-action-hover-color}; --bs-list-group-action-hover-bg: #{$list-group-hover-bg}; --bs-list-group-action-active-color: #{$list-group-action-active-color}; --bs-list-group-action-active-bg: #{$list-group-action-active-bg}; --bs-list-group-disabled-color: #{$list-group-disabled-color}; --bs-list-group-disabled-bg: #{$list-group-disabled-bg}; --bs-list-group-active-color: #{$list-group-active-color}; --bs-list-group-active-bg: #{$list-group-active-bg}; --bs-list-group-active-border-color: #{$list-group-active-border-color}; --wg-link-underline-color: #{$link-underline-color}; --wg-link-hover-underline-color: #{$link-hover-underline-color}; } @each $theme-color, $value in $theme-colors { .text-bg-#{$theme-color} { $color: color-contrast($value); $real-background: if($color == $color-contrast-dark, shade-color($value, $btn-bg-shade-amount), tint-color($value, $btn-bg-tint-amount)); $real-color: if($color == $color-contrast-light, shade-color($value, $btn-color-shade-amount), tint-color($value, $btn-color-tint-amount)); color: $real-color if($enable-important-utilities, !important, null); background-color: $real-background if($enable-important-utilities, !important, null); } } ul.pagination { display: flex; align-items: center; } .container-max-sm { margin: auto; max-width: 540px; } .container-max-md { margin: auto; max-width: 720px; } .container-max-lg { margin: auto; max-width: 960px; } .modal-content { box-shadow: 0 0 10px rgba(0, 0, 0, .25); } .modal-header { padding-bottom: 0; h5 { margin: auto; text-align: center; } } .modal-footer { border: none; display: flex; padding-top: 0; .modal-button { display: block; flex: 1 0 0; } @media (max-width: 576px) { flex-direction: column; align-items: stretch; .modal-button { flex: auto; } } } @media (prefers-reduced-motion: reduce) { // #1271: sveltestrap modals are broken when `prefers-reduced-motion` is on .modal.fade { opacity: 1 !important; } .modal-backdrop.fade { opacity: var(--bs-backdrop-opacity) !important; } } .abbreviate { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .dropdown-item { display: flex; align-items: center; gap: 0.5rem; } ================================================ FILE: warpgate-web/src/theme/fonts.css ================================================ @font-face { font-family: 'Poppins'; font-style: normal; font-display: block; /* changed */ font-weight: 700; src: url(./../../node_modules/@fontsource/poppins/files/poppins-latin-700-normal.woff2) format('woff2'), url(./../../node_modules/@fontsource/poppins/files/poppins-latin-700-normal.woff) format('woff'); } ================================================ FILE: warpgate-web/src/theme/index.ts ================================================ import '@fontsource/work-sans' import './fonts.css' import { get, writable } from 'svelte/store' type ThemeFileName = 'dark'|'light' type ThemeName = ThemeFileName|'auto' const savedTheme = (localStorage.getItem('theme') ?? 'auto') as ThemeName export const currentTheme = writable(savedTheme) export const currentThemeFile = writable('dark') const styleElement = document.createElement('style') document.head.appendChild(styleElement) function loadThemeFile (name: ThemeFileName) { currentThemeFile.set(name) if (name === 'dark') { return import('./theme.dark.scss?inline') } return import('./theme.light.scss?inline') } async function loadTheme (name: ThemeFileName) { const theme = (await loadThemeFile(name)).default styleElement.innerHTML = theme } window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => { if (get(currentTheme) === 'auto') { loadTheme(event.matches ? 'dark' : 'light') } }) export function setCurrentTheme (theme: ThemeName): void { localStorage.setItem('theme', theme) currentTheme.set(theme) if (theme === 'auto') { if (window.matchMedia?.('(prefers-color-scheme: dark)').matches) { loadTheme('dark') } else { loadTheme('light') } } else { loadTheme(theme) } } setCurrentTheme(savedTheme) ================================================ FILE: warpgate-web/src/theme/theme.dark.scss ================================================ @import "./vars.dark"; @import "bootstrap/scss/functions"; @import "bootstrap/scss/maps"; @import "bootstrap/scss/mixins"; @mixin button-outline-variant( $color, $color-hover: color-contrast($color), $active-background: $color, $active-border: $color, $active-color: color-contrast($active-background) ) { --#{$prefix}btn-color: lighten(#{$color}, 50%); --#{$prefix}btn-border-color: #{$color}; --#{$prefix}btn-hover-color: #{$color-hover}; --#{$prefix}btn-hover-bg: #{$active-background}; --#{$prefix}btn-hover-border-color: #{$active-border}; --#{$prefix}btn-focus-shadow-rgb: #{to-rgb($color)}; --#{$prefix}btn-active-color: #{$active-color}; --#{$prefix}btn-active-bg: #{$active-background}; --#{$prefix}btn-active-border-color: #{$active-border}; --#{$prefix}btn-active-shadow: #{$btn-active-box-shadow}; --#{$prefix}btn-disabled-color: #{$color}; --#{$prefix}btn-disabled-bg: transparent; --#{$prefix}gradient: none; } @import "bootstrap/scss/utilities"; @import "theme"; header { border-bottom: 1px solid rgba($body-color, .2); } .list-group-item-action { transition: 0.125s ease-out background; } body { color-scheme: dark; } ================================================ FILE: warpgate-web/src/theme/theme.light.scss ================================================ @import "./vars.light"; @import "bootstrap/scss/functions"; @import "bootstrap/scss/maps"; @import "bootstrap/scss/mixins"; @import "bootstrap/scss/utilities"; @import "theme"; header { border-bottom: 1px solid rgba($body-color, .75); } body { color-scheme: light; } ================================================ FILE: warpgate-web/src/theme/vars.common.scss ================================================ $pagination-bg: transparent; $pagination-disabled-bg: transparent; $pagination-disabled-color: $btn-link-disabled-color; $pagination-border-width: 0; $pagination-active-color: $link-hover-color; $pagination-active-bg: transparent; $pagination-hover-bg: transparent; $pagination-focus-bg: transparent; $modal-header-border-color: transparent; $dropdown-link-hover-bg: transparent; $btn-padding-x: 1.5rem; $btn-bg-shade-amount: 75%; $btn-bg-tint-amount: 60%; $btn-border-shade-amount: 75%; $btn-border-tint-amount: 65%; $btn-color-shade-amount: 10%; $btn-color-tint-amount: 50%; $btn-hover-bg-shade-amount: 60%; $btn-hover-bg-tint-amount: 50%; $btn-hover-border-shade-amount: 60%; $btn-hover-border-tint-amount: 10%; $btn-active-bg-shade-amount: 50%; // $btn-active-bg-tint-amount $btn-active-border-shade-amount: 40%; // $btn-active-border-tint-amount $tooltip-color: #c1c9e4; $badge-font-size: .8em; $badge-font-weight: 400; $badge-padding-y: .55em; $badge-padding-x: .85em; $alert-border-width: 0; $alert-border-scale: -30%; $table-color: var(--bs-body-color); $table-bg: var(--bs-body-bg); ================================================ FILE: warpgate-web/src/theme/vars.dark.scss ================================================ @import "bootstrap/scss/functions"; $body-bg: #14141a; $body-color: #c1c9e4; $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; $blue: #449fbe; $purple: #5C398F; $pink: #B53D6D; $red: #D35D47; $orange: #fd7e14; $yellow: #D38F47; $green: #87C041; $teal: #20c997; $cyan: #0dcaf0; $primary: $blue; $secondary: #748d95; $light: transparent; $info: $blue; $component-active-bg: $primary; $input-bg: #1c1c24; $input-border-color: #ced4da40; $input-color: #ccc; $input-focus-bg: #1b1b22; $input-focus-border-color: tint-color($component-active-bg, 25%) ; $input-disabled-bg: $input-bg; $input-btn-focus-color-opacity: .25; $input-btn-border-width: 2px; $border-color: rgba(#fff, .125); $list-group-color: $body-color; $list-group-border-color: $border-color; $list-group-hover-bg: rgba(#fff, .0625); $list-group-action-color: $body-color; $list-group-action-hover-color: $body-color; $list-group-action-active-color: $body-color; $list-group-action-active-bg: rgba(#fff, .125); $link-hover-color: lighten($link-color, 25%); $badge-color: $body-bg; $text-muted: rgba($body-color, .5); $modal-content-bg: $body-bg; $btn-close-color: $secondary; $btn-link-disabled-color: rgba(255, 255, 255, .5); $btn-disabled-color: #969696; $alert-bg-scale: 100%; $alert-border-scale: 50%; $alert-color-scale: 0%; $code-color: #84f1fe; $form-check-input-border: 2px solid $input-border-color; $form-switch-color: $secondary; $form-check-color: $primary; @import "bootstrap/scss/variables"; @import "./vars.common.scss"; $link-underline-color: rgba($link-color, .5); $link-hover-underline-color: rgba($link-hover-color, .75); $form-check-input-width: 1.3em; $form-switch-width: 2.5em; $form-switch-padding-start: 3em; $form-check-input-checked-border-color: shade-color($form-check-color, $btn-active-border-shade-amount); $form-check-input-checked-bg-color: shade-color($form-check-color, $btn-active-bg-shade-amount); $primary-bg-subtle: shade-color($primary, 80%); $secondary-bg-subtle: shade-color($secondary, 80%); $success-bg-subtle: shade-color($success, 80%); $info-bg-subtle: shade-color($info, 80%); $warning-bg-subtle: shade-color($warning, 80%); $danger-bg-subtle: shade-color($danger, 80%); $primary-text-emphasis: tint-color($primary, 60%); $secondary-text-emphasis: tint-color($secondary, 60%); $success-text-emphasis: tint-color($success, 60%); $info-text-emphasis: tint-color($info, 60%); $warning-text-emphasis: tint-color($warning, 60%); $danger-text-emphasis: tint-color($danger, 60%); ================================================ FILE: warpgate-web/src/theme/vars.light.scss ================================================ @import "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; $blue: #3573ac !default; $purple: #5C398F !default; $pink: #B53D6D !default; $red: #842716 !default; $orange: #a84c01 !default; $yellow: #916101 !default; $green: #417106 !default; $teal: #20c997 !default; $cyan: #0e6b7e !default; // $primary: $blue; $secondary: #506579; $btn-disabled-color: #969696; $form-switch-color: $secondary; // $form-check-color: $primary; $form-check-input-border: 2px solid rgba(#000, .25); @import "bootstrap/scss/variables"; @import "./vars.common.scss"; $form-check-color: $primary; $text-muted: $gray-500; $link-underline-color: rgba($body-color, 0.25); $link-hover-underline-color: $body-color; $form-check-input-width: 1.3em; $form-switch-width: 2.5em; $form-switch-padding-start: 3em; $form-check-input-checked-border-color: tint-color($form-check-color, $btn-active-border-tint-amount); $form-check-input-checked-bg-color: tint-color($form-check-color, $btn-active-bg-tint-amount); $primary-bg-subtle: tint-color($primary, 90%); $secondary-bg-subtle: tint-color($secondary, 90%); $success-bg-subtle: tint-color($success, 90%); $info-bg-subtle: tint-color($info, 90%); $warning-bg-subtle: tint-color($warning, 90%); $danger-bg-subtle: tint-color($danger, 90%); $primary-text-emphasis: shade-color($primary, 10%); $secondary-text-emphasis: shade-color($secondary, 10%); $success-text-emphasis: shade-color($success, 10%); $info-text-emphasis: shade-color($info, 10%); $warning-text-emphasis: shade-color($warning, 10%); $danger-text-emphasis: shade-color($danger, 10%); ================================================ FILE: warpgate-web/src/vite-env.d.ts ================================================ /// /// // eslint-disable-next-line @typescript-eslint/no-type-alias declare type GlobalFetch = WindowOrWorkerGlobalScope ================================================ FILE: warpgate-web/svelte.config.js ================================================ import sveltePreprocess from 'svelte-preprocess' /** @type {import('@sveltejs/kit').Config} */ const config = { compilerOptions: { dev: true, compatibility: { componentApi: 4, }, }, preprocess: sveltePreprocess({ sourceMap: true, }), vitePlugin: { prebundleSvelteLibraries: true, }, } export default config ================================================ FILE: warpgate-web/tsconfig.json ================================================ { "extends": "@tsconfig/svelte/tsconfig.json", "compilerOptions": { "target": "esnext", "useDefineForClassFields": true, "module": "esnext", "resolveJsonModule": true, "strictNullChecks": true, "baseUrl": ".", "verbatimModuleSyntax": true, "noUnusedLocals": false, "noUncheckedIndexedAccess": true, /** * 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. */ "types": [], "allowJs": true, "checkJs": true, "paths": { "*": [ "src/*" ] } }, "include": [ "src/**/*.d.ts", "src/**/*.ts", "src/*.ts", "src/**/*.js", "src/**/*.svelte" ], "exclude": [ "node_modules/@types/node/**", "src/*/lib/api-client", ], "references": [ { "path": "./tsconfig.node.json" } ] } ================================================ FILE: warpgate-web/tsconfig.node.json ================================================ { "compilerOptions": { "composite": true, "module": "esnext", "moduleResolution": "node" }, "include": ["vite.config.ts"] } ================================================ FILE: warpgate-web/vite.config.ts ================================================ import { defineConfig } from 'vite' import { svelte } from '@sveltejs/vite-plugin-svelte' import tsconfigPaths from 'vite-tsconfig-paths' import { checker } from 'vite-plugin-checker' // https://vitejs.dev/config/ export default defineConfig({ plugins: [ svelte(), tsconfigPaths(), // checker({ typescript: true }), ], base: '/@warpgate', build: { sourcemap: true, manifest: true, commonjsOptions: { include: [ 'src/gateway/lib/api-client/dist/*.js', 'src/admin/lib/api-client/dist/*.js', '**/*.js', ], transformMixedEsModules: true, }, rollupOptions: { input: { admin: 'src/admin/index.html', gateway: 'src/gateway/index.html', embed: 'src/embed/index.ts', }, }, }, })