Compare commits
185 Commits
v1.0-stabl
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
564ee12988 | ||
| 14cbe3b9f2 | |||
| fe3f8885c6 | |||
| 760ae5cf8d | |||
| a5be141bac | |||
| 7ac73b9489 | |||
| f72a4416e3 | |||
|
|
80604075d0 | ||
|
|
c8312348fd | ||
|
|
372dc43ef2 | ||
|
|
7472c7b2c1 | ||
|
|
d76ccf324a | ||
|
|
9c6d056d69 | ||
|
|
bc36b2b0cb | ||
|
|
a963ca0d00 | ||
|
|
531af7d2d5 | ||
|
|
2e7080e674 | ||
|
|
d99316d947 | ||
|
|
c5ecf67f29 | ||
|
|
2d9a368fc4 | ||
|
|
5d30deb48c | ||
|
|
95a8623cb6 | ||
|
|
f95ff94a76 | ||
|
|
447599b92a | ||
|
|
37c14c4615 | ||
|
|
a7ea3121d9 | ||
|
|
7bf2a59838 | ||
|
|
ec63346c8a | ||
|
|
dc60652b83 | ||
|
|
79f8d8f545 | ||
|
|
46620c87b7 | ||
|
|
f70348c18a | ||
|
|
e477717716 | ||
|
|
c45d57f0c1 | ||
|
|
a21b266e0d | ||
|
|
841c688df0 | ||
|
|
e939275a8a | ||
|
|
d532838a25 | ||
|
|
1b1d54b572 | ||
|
|
91e382c586 | ||
|
|
f75321f6b7 | ||
|
|
cb7f09debb | ||
|
|
1006f2bac9 | ||
|
|
32693f4197 | ||
|
|
43ac78bf57 | ||
|
|
918f1b9e93 | ||
|
|
da70e5967b | ||
|
|
43493e9548 | ||
|
|
5d92dcff8d | ||
|
|
ecbe63f175 | ||
|
|
a67962b02d | ||
|
|
8e117ad083 | ||
|
|
6325c0c4b7 | ||
|
|
db00db131b | ||
|
|
71e3668943 | ||
|
|
4cb8216c49 | ||
|
|
bce7ed0164 | ||
|
|
f72613c2ba | ||
|
|
017f338d86 | ||
|
|
93d77f297e | ||
|
|
cd58eeef81 | ||
|
|
e9e592b43a | ||
|
|
6b32631434 | ||
|
|
cdc497f365 | ||
|
|
769abae3ba | ||
|
|
303ed64744 | ||
|
|
0665f92879 | ||
|
|
acd98367db | ||
|
|
9d9b7a9ab5 | ||
|
|
31c472feaf | ||
|
|
cdf6e68684 | ||
|
|
b11eb0879e | ||
|
|
1ff38730d2 | ||
|
|
43eb2d5f47 | ||
|
|
2abf83f0be | ||
|
|
236ddb4f9b | ||
|
|
7e3c4abea7 | ||
|
|
ee20553f08 | ||
|
|
db98550455 | ||
|
|
b42c3cd3cc | ||
|
|
bbd0735289 | ||
|
|
df175da330 | ||
|
|
81abbf7633 | ||
|
|
04988650e8 | ||
|
|
e3ae09b525 | ||
|
|
0566f90488 | ||
|
|
94d7cddc4b | ||
|
|
eb1b287751 | ||
|
|
ce6692d22f | ||
|
|
8aa7c8cbb7 | ||
|
|
df3ca7917c | ||
|
|
cc6420b037 | ||
|
|
51af7d2ce4 | ||
|
|
96030f79f3 | ||
|
|
090a5797d0 | ||
|
|
0c5f93e810 | ||
|
|
2a2edaa21c | ||
|
|
454d203070 | ||
|
|
a15f8f3705 | ||
|
|
232be245d9 | ||
|
|
e4e132c273 | ||
|
|
b92469cbe3 | ||
|
|
61489fc4a7 | ||
|
|
28890a97b6 | ||
|
|
184ff320b8 | ||
|
|
19e9e3269d | ||
|
|
c76de478e2 | ||
|
|
f94e73b1ec | ||
|
|
50f6d343f1 | ||
|
|
96dcc62e11 | ||
|
|
159cf56154 | ||
|
|
8a911328b9 | ||
|
|
e56c133178 | ||
|
|
be77ffef12 | ||
|
|
c05e9ee127 | ||
|
|
16c087d3bf | ||
|
|
768ee45974 | ||
|
|
9d2dc57e90 | ||
|
|
0901a117c9 | ||
|
|
69bcf81230 | ||
|
|
fb58cb299e | ||
|
|
390f6acbcd | ||
|
|
6499f9b7a5 | ||
|
|
cb8b269478 | ||
|
|
8bc3e25f83 | ||
|
|
45a57f44ff | ||
|
|
40d9cdda57 | ||
|
|
3ca05cbf16 | ||
|
|
790ea0369c | ||
|
|
bdd0155dbb | ||
|
|
b05886db73 | ||
|
|
cd4182c1f5 | ||
|
|
8ef2e3c153 | ||
|
|
b28d1787fc | ||
|
|
1f7d8620d9 | ||
|
|
6a7d99cbba | ||
|
|
1ac36df867 | ||
|
|
954ad4dc4c | ||
|
|
c8acaeff0e | ||
|
|
e06cec9729 | ||
|
|
59b5022d37 | ||
|
|
071ffd1e6c | ||
|
|
d0001d0dc6 | ||
|
|
7a5c4a1d8f | ||
|
|
d91818d515 | ||
|
|
ba0be98b68 | ||
|
|
62db3c54e3 | ||
|
|
d499095022 | ||
|
|
dd67e4f7c2 | ||
|
|
aeb777624a | ||
|
|
e91fd3c78c | ||
|
|
c3e251ecae | ||
|
|
bd876d2df8 | ||
|
|
b2f82c4ab9 | ||
|
|
9365fb03dc | ||
|
|
1cc667897e | ||
|
|
dfb82889d3 | ||
|
|
ce84fd9cf1 | ||
|
|
b83f876687 | ||
|
|
6917beb0d2 | ||
|
|
008ae03557 | ||
|
|
a548d2b30a | ||
|
|
0f329f8e55 | ||
|
|
48da75b96d | ||
|
|
281cf1283c | ||
|
|
db9dfe3424 | ||
|
|
d653997d4d | ||
|
|
53eaff3eaa | ||
|
|
9699288b82 | ||
|
|
75b20383d9 | ||
|
|
2a11a23586 | ||
|
|
45132813c2 | ||
|
|
2fcead680c | ||
|
|
bd10329828 | ||
|
|
7c845e5b7d | ||
|
|
7f56569d42 | ||
|
|
e5e888ec7c | ||
|
|
bc6df00305 | ||
|
|
20d809896d | ||
|
|
188ce3586d | ||
|
|
bf362b4e52 | ||
|
|
bb2501c7a6 | ||
|
|
34df6599cb | ||
|
|
3919a7e055 | ||
|
|
505eafdc1e |
80
.github/workflows/codeql-analysis.yml
vendored
Normal file
80
.github/workflows/codeql-analysis.yml
vendored
Normal file
@ -0,0 +1,80 @@
|
||||
# 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"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ "master" ]
|
||||
schedule:
|
||||
- cron: '22 22 * * 3'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'go' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||
|
||||
steps:
|
||||
- name: Install pam-devel
|
||||
run: sudo apt-get -y install libpam-dev
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# 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.
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
|
||||
# - run: |
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
48
.github/workflows/docker-image.yml
vendored
Normal file
48
.github/workflows/docker-image.yml
vendored
Normal file
@ -0,0 +1,48 @@
|
||||
name: Docker Image CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
tags: [ "v*" ]
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and push - latest
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ./dev/docker
|
||||
file: ./dev/docker/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ github.repository_owner }}/rdpgw:latest
|
||||
- name: Build and push - latest
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ./dev/docker
|
||||
file: ./dev/docker/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ github.repository_owner }}/rdpgw:${{ github.ref_name }}
|
||||
- name: Update Docker Hub Description
|
||||
uses: peter-evans/dockerhub-description@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
repository: ${{ github.repository_owner }}/rdpgw
|
||||
readme-filepath: ./dev/docker/docker-readme.md
|
||||
12
.github/workflows/go.yml
vendored
12
.github/workflows/go.yml
vendored
@ -16,17 +16,23 @@ jobs:
|
||||
- name: Set up Go 1.x
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ^1.14
|
||||
go-version: ^1.22
|
||||
id: go
|
||||
|
||||
- name: Install pam-devel
|
||||
run: sudo apt-get -y install libpam-dev
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Install golint
|
||||
run: go get -u golang.org/x/lint/golint
|
||||
|
||||
- name: Update go.sum
|
||||
run: make mod
|
||||
|
||||
- name: Build
|
||||
run: go build -v .
|
||||
run: make build
|
||||
|
||||
- name: Test
|
||||
run: go test -cover -v ./...
|
||||
run: make test
|
||||
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
go.sum
|
||||
node_modules/
|
||||
package-lock.json
|
||||
78
Makefile
Normal file
78
Makefile
Normal file
@ -0,0 +1,78 @@
|
||||
BINDIR := $(CURDIR)/bin
|
||||
INSTALL_PATH ?= /usr/local/bin
|
||||
BINNAME ?= rdpgw
|
||||
BINNAME2 ?= rdpgw-auth
|
||||
|
||||
# Rebuild the binary if any of these files change
|
||||
SRC := $(shell find . -type f -name '*.go' -print) go.mod go.sum
|
||||
|
||||
# Required for globs to work correctly
|
||||
SHELL = /usr/bin/env bash
|
||||
|
||||
GIT_COMMIT = $(shell git rev-parse HEAD)
|
||||
GIT_SHA = $(shell git rev-parse --short HEAD)
|
||||
GIT_TAG = $(shell git describe --tags --abbrev=0 --exact-match 2>/dev/null)
|
||||
GIT_DIRTY = $(shell test -n "`git status --porcelain`" && echo "dirty" || echo "clean")
|
||||
|
||||
ifdef VERSION
|
||||
BINARY_VERSION = $(VERSION)
|
||||
endif
|
||||
BINARY_VERSION ?= ${GIT_TAG}
|
||||
|
||||
VERSION_METADATA = unreleased
|
||||
# Clear the "unreleased" string in BuildMetadata
|
||||
ifneq ($(GIT_TAG),)
|
||||
VERSION_METADATA =
|
||||
endif
|
||||
|
||||
.PHONY: all
|
||||
all: mod build deb
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# build
|
||||
|
||||
.PHONY: build
|
||||
build: $(BINDIR)/$(BINNAME)
|
||||
|
||||
$(BINDIR)/$(BINNAME): $(SRC)
|
||||
go build $(GOFLAGS) -trimpath -tags '$(TAGS)' -ldflags '$(LDFLAGS)' -o '$(BINDIR)'/$(BINNAME) ./cmd/rdpgw
|
||||
go build $(GOFLAGS) -trimpath -tags '$(TAGS)' -ldflags '$(LDFLAGS)' -o '$(BINDIR)'/$(BINNAME2) ./cmd/auth
|
||||
|
||||
.PHONY: deb
|
||||
deb:clean mod build
|
||||
dpkg-buildpackage -b -us -uc
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# install
|
||||
|
||||
.PHONY: install
|
||||
install: build
|
||||
@install "$(BINDIR)/$(BINNAME)" "$(INSTALL_PATH)/$(BINNAME)"
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# mod
|
||||
|
||||
.PHONY: mod
|
||||
mod:
|
||||
go mod tidy -compat=1.23
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# test
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
go test -cover -v ./...
|
||||
# ------------------------------------------------------------------------------
|
||||
# clean
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
@rm -rf '$(BINDIR)' ./_dist
|
||||
dh_clean
|
||||
|
||||
.PHONY: info
|
||||
info:
|
||||
@echo "Version: ${VERSION}"
|
||||
@echo "Git Tag: ${GIT_TAG}"
|
||||
@echo "Git Commit: ${GIT_COMMIT}"
|
||||
@echo "Git Tree State: ${GIT_DIRTY}"
|
||||
540
README.md
540
README.md
@ -2,6 +2,10 @@ GO Remote Desktop Gateway
|
||||
=========================
|
||||
|
||||

|
||||
[](https://hub.docker.com/r/bolkedebruin/rdpgw/)
|
||||
[](https://hub.docker.com/r/bolkedebruin/rdpgw/)
|
||||
[](https://hub.docker.com/r/bolkedebruin/rdpgw/)
|
||||
|
||||
|
||||
:star: Star us on GitHub — it helps!
|
||||
|
||||
@ -10,90 +14,329 @@ This allows you to connect with the official Microsoft clients to remote desktop
|
||||
These desktops could be, for example, [XRDP](http://www.xrdp.org) desktops running in containers
|
||||
on Kubernetes.
|
||||
|
||||
## AIM
|
||||
# AIM
|
||||
RDPGW aims to provide a full open source replacement for MS Remote Desktop Gateway,
|
||||
including access policies.
|
||||
|
||||
## Multi Factor Authentication (MFA)
|
||||
RDPGW provides multi factor authentication out of the box with OpenID Connect integration. Thus
|
||||
you can integrate your remote desktops with Keycloak, Okta, Google, Azure, Apple or Facebook
|
||||
if you want.
|
||||
# Security requirements
|
||||
|
||||
## Security
|
||||
RDPGW wants to be secure when you set it up from the beginning. It does this by having OpenID
|
||||
Connect integration enabled by default. Cookies are encrypted and signed on the client side relying
|
||||
Several security requirements are stipulated by the client that is connecting to it and some are
|
||||
enforced by the gateway. The client requires that the server's TLS certificate is valid and that
|
||||
it is signed by a trusted authority. In addition, the common name in the certificate needs to
|
||||
match the DNS hostname of the gateway. If these requirements are not met the client will refuse
|
||||
to connect.
|
||||
|
||||
The gateway has several security phases. In the authentication phase the client's credentials are
|
||||
verified. Depending the authentication mechanism used, the client's credentials are verified against
|
||||
an OpenID Connect provider, Kerberos, a local PAM service or a local database.
|
||||
|
||||
If OpenID Connect is used the user will
|
||||
need to connect to a webpage provided by the gateway to authenticate, which in turn will redirect
|
||||
the user to the OpenID Connect provider. If the authentication is successful the browser will download
|
||||
a RDP file with temporary credentials that allow the user to connect to the gateway by using a remote
|
||||
desktop client.
|
||||
|
||||
If Kerberos is used the client will need to have a valid ticket granting ticket (TGT). The gateway
|
||||
will proxy the TGT request to the KDC. Therefore, the gateway needs to be able to connect to the KDC
|
||||
and a krb5.conf file needs to be provided. The proxy works without the need for an RDP file and thus
|
||||
the client can connect directly to the gateway.
|
||||
|
||||
If local authentication is used the client will need to provide a username and password that is verified
|
||||
against PAM. This requires, to ensure privilege separation, that ```rdpgw-auth``` is also running and a
|
||||
valid PAM configuration is provided per typical configuration.
|
||||
|
||||
If NTLM authentication is used, the allowed user credentials for the gateway should be configured in the
|
||||
configuration file of `rdpgw-auth`.
|
||||
|
||||
Finally, RDP hosts that the client wants to connect to are verified against what was provided by / allowed by
|
||||
the server. Next to that the client's ip address needs to match the one it obtained the gateway token with if
|
||||
using OpenID Connect. Due to proxies and NAT this is not always possible and thus can be disabled. However, this
|
||||
is a security risk.
|
||||
|
||||
# Configuration
|
||||
The configuration is done through a YAML file. The configuration file is read from `rdpgw.yaml` by default.
|
||||
At the bottom of this README is an example configuration file. In these sections you will find the most important
|
||||
settings.
|
||||
|
||||
## Authentication
|
||||
|
||||
RDPGW wants to be secure when you set it up from the start. It supports several authentication
|
||||
mechanisms such as OpenID Connect, Kerberos, PAM or NTLM.
|
||||
|
||||
Technically, cookies are encrypted and signed on the client side relying
|
||||
on [Gorilla Sessions](https://www.gorillatoolkit.org/pkg/sessions). PAA tokens (gateway access tokens)
|
||||
are generated and signed according to the JWT spec by using [jwt-go](https://github.com/dgrijalva/jwt-go)
|
||||
signed with a 256 bit HMAC. Hosts provided by the user are verified against what was provided by
|
||||
the server. Finally, the client's ip address needs to match the one it obtained the token with.
|
||||
signed with a 256 bit HMAC.
|
||||
|
||||
## How to build
|
||||
```bash
|
||||
cd rdpgw
|
||||
go build -o rdpgw .
|
||||
### Multi Factor Authentication (MFA)
|
||||
RDPGW provides multi-factor authentication out of the box with OpenID Connect integration. Thus
|
||||
you can integrate your remote desktops with Keycloak, Okta, Google, Azure, Apple or Facebook
|
||||
if you want.
|
||||
|
||||
### Mixing authentication mechanisms
|
||||
|
||||
It is technically possible to mix authentication mechanisms. Currently, you can mix local with Kerberos or NTLM. If you enable
|
||||
OpenID Connect it is not possible to mix it with local or Kerberos at the moment.
|
||||
|
||||
### Open ID Connect
|
||||

|
||||
|
||||
To use OpenID Connect make sure you have properly configured your OpenID Connect provider, and you have a client id
|
||||
and secret. The client id and secret are used to authenticate the gateway to the OpenID Connect provider. The provider
|
||||
will then authenticate the user and provide the gateway with a token. The gateway will then use this token to generate
|
||||
a PAA token that is used to connect to the RDP host.
|
||||
|
||||
To enable OpenID Connect make sure to set the following variables in the configuration file.
|
||||
|
||||
```yaml
|
||||
Server:
|
||||
Authentication:
|
||||
- openid
|
||||
OpenId:
|
||||
ProviderUrl: http://<provider_url>
|
||||
ClientId: <your client id>
|
||||
ClientSecret: <your-secret>
|
||||
Caps:
|
||||
TokenAuth: true
|
||||
```
|
||||
|
||||
## Configuration
|
||||
By default the configuration is read from `rdpgw.yaml`. Below is a
|
||||
template.
|
||||
As you can see in the flow diagram when using OpenID Connect the user will use a browser to connect to the gateway first at
|
||||
https://your-gateway/connect. If authentication is successful the browser will download a RDP file with temporary credentials
|
||||
that allow the user to connect to the gateway by using a remote desktop client.
|
||||
|
||||
### Kerberos
|
||||

|
||||
|
||||
__NOTE__: Kerberos is heavily reliant on DNS (forward and reverse). Make sure that your DNS is properly configured.
|
||||
Next to that, its errors are not always very descriptive. It is beyond the scope of this project to provide a full
|
||||
Kerberos tutorial.
|
||||
|
||||
To use Kerberos make sure you have a keytab and krb5.conf file. The keytab is used to authenticate the gateway to the KDC
|
||||
and the krb5.conf file is used to configure the KDC. The keytab needs to contain a valid principal for the gateway.
|
||||
|
||||
Use `ktutil` or a similar tool provided by your Kerberos server to create a keytab file for the newly created service principal.
|
||||
Place this keytab file in a secure location on the server and make sure that the file is only readable by the user that runs
|
||||
the gateway.
|
||||
|
||||
```plaintext
|
||||
ktutil
|
||||
addent -password -p HTTP/rdpgw.example.com@YOUR.REALM -k 1 -e aes256-cts-hmac-sha1-96
|
||||
wkt rdpgw.keytab
|
||||
```
|
||||
|
||||
Then set the following in the configuration file.
|
||||
|
||||
```yaml
|
||||
Server:
|
||||
Authentication:
|
||||
- kerberos
|
||||
Kerberos:
|
||||
Keytab: /etc/keytabs/rdpgw.keytab
|
||||
Krb5conf: /etc/krb5.conf
|
||||
Caps:
|
||||
TokenAuth: false
|
||||
```
|
||||
|
||||
The client can then connect directly to the gateway without the need for a RDP file.
|
||||
|
||||
|
||||
### PAM / Local (Basic Auth)
|
||||

|
||||
|
||||
The gateway can also support authentication against PAM. Sometimes this is referred to as local or passwd authentication,
|
||||
but it also supports LDAP authentication or even Active Directory if you have the correct modules installed. Typically
|
||||
(for passwd), PAM requires that it is accessed as root. Therefore, the gateway comes with a small helper program called
|
||||
`rdpgw-auth` that is used to authenticate the user. This program needs to be run as root or setuid.
|
||||
|
||||
__NOTE__: The default windows client ``mstsc`` does not support basic auth. You will need to use a different client or
|
||||
switch to OpenID Connect, Kerberos or NTLM authentication.
|
||||
|
||||
__NOTE__: Using PAM for passwd (i.e. LDAP is fine) within a container is not recommended. It is better to use OpenID
|
||||
Connect or Kerberos. If you do want to use it within a container you can choose to run the helper program outside the
|
||||
container and have the socket available within. Alternatively, you can mount all what is needed into the container but
|
||||
PAM is quite sensitive to the environment.
|
||||
|
||||
Ensure you have a PAM service file for the gateway, `/etc/pam.d/rdpgw`. For authentication against local accounts on the
|
||||
host located in `/etc/passwd` and `/etc/shadow` you can use the following.
|
||||
|
||||
```plaintext
|
||||
auth required pam_unix.so
|
||||
account required pam_unix.so
|
||||
```
|
||||
|
||||
Then set the following in the configuration file.
|
||||
|
||||
```yaml
|
||||
Server:
|
||||
Authentication:
|
||||
- local
|
||||
AuthSocket: /tmp/rdpgw-auth.sock
|
||||
Caps:
|
||||
TokenAuth: false
|
||||
```
|
||||
|
||||
Make sure to run both the gateway and `rdpgw-auth`. The gateway will connect to the socket to authenticate the user.
|
||||
|
||||
```bash
|
||||
# ./rdpgw-auth -n rdpgw -s /tmp/rdpgw-auth.sock
|
||||
```
|
||||
|
||||
The client can then connect to the gateway directly by using a remote desktop client.
|
||||
|
||||
### NTLM
|
||||
|
||||
The gateway can also support NTLM authentication.
|
||||
Currently, only the configuration file is supported as a database for credential lookup.
|
||||
In the future, support for real databases (e.g. sqlite) may be added.
|
||||
|
||||
NTLM authentication has the advantage that it is easy to setup, especially in case the gateway is used for a limited number of users.
|
||||
Unlike PAM / local, NTLM authentication supports the default windows client ``mstsc``.
|
||||
|
||||
__WARNING__: The password is currently saved in plain text. So, you should keep the config file as secure as possible and avoid
|
||||
reusing the same password for other applications. The password is stored in plain text to support the NTLM authentication protocol.
|
||||
|
||||
To enable NTLM authentication make sure to set the following variables in the configuration file.
|
||||
|
||||
Configuration file for `rdpgw`:
|
||||
```yaml
|
||||
Server:
|
||||
Authentication:
|
||||
- ntlm
|
||||
Caps:
|
||||
TokenAuth: false
|
||||
```
|
||||
|
||||
Configuration file for `rdpgw-auth`:
|
||||
````yaml
|
||||
Users:
|
||||
- {Username: "my_username", Password: "my_secure_password"} # Modify this password!
|
||||
````
|
||||
|
||||
The client can then connect to the gateway directly by using a remote desktop client using the gateway credentials
|
||||
configured in the YAML configuration file.
|
||||
|
||||
## TLS
|
||||
|
||||
The gateway requires a valid TLS certificate. This means a certificate that is signed by a valid CA that is in the store
|
||||
of your clients. If this is not the case particularly Windows clients will fail to connect. You can either provide a
|
||||
certificate and key file or let the gateway obtain a certificate from letsencrypt. If you want to use letsencrypt make
|
||||
sure that the host is reachable on port 80 from the letsencrypt servers.
|
||||
|
||||
For letsencrypt:
|
||||
|
||||
```yaml
|
||||
Tls: auto
|
||||
```
|
||||
|
||||
for your own certificate:
|
||||
```yaml
|
||||
Tls: enable
|
||||
CertFile: server.pem
|
||||
KeyFile: key.pem
|
||||
```
|
||||
|
||||
__NOTE__: You can disable TLS on the gateway, but you will then need to make sure a proxy is run in front of it that does
|
||||
TLS termination.
|
||||
|
||||
|
||||
## Example configuration file for Open ID Connect
|
||||
|
||||
```yaml
|
||||
# web server configuration.
|
||||
server:
|
||||
# TLS certificate files (required)
|
||||
certFile: server.pem
|
||||
keyFile: key.pem
|
||||
# gateway address advertised in the rdp files
|
||||
gatewayAddress: localhost
|
||||
# port to listen on
|
||||
port: 443
|
||||
Server:
|
||||
# can be set to openid, kerberos, local and ntlm. If openid is used rdpgw expects
|
||||
# a configured openid provider, make sure to set caps.tokenauth to true. If local
|
||||
# rdpgw connects to rdpgw-auth over a socket to verify users and password. Note:
|
||||
# rdpgw-auth needs to be run as root or setuid in order to work. If kerberos is
|
||||
# used a keytab and krb5conf need to be supplied. local can be stacked with
|
||||
# kerberos or ntlm authentication, so that the clients selects what it wants.
|
||||
Authentication:
|
||||
# - kerberos
|
||||
# - local
|
||||
- openid
|
||||
# - ntlm
|
||||
# The socket to connect to if using local auth. Ensure rdpgw auth is configured to
|
||||
# use the same socket.
|
||||
# AuthSocket: /tmp/rdpgw-auth.sock
|
||||
# Basic auth timeout (in seconds). Useful if you're planning on waiting for MFA
|
||||
BasicAuthTimeout: 5
|
||||
# The default option 'auto' uses a certificate file if provided and found otherwise
|
||||
# it uses letsencrypt to obtain a certificate, the latter requires that the host is reachable
|
||||
# from letsencrypt servers. If TLS termination happens somewhere else (e.g. a load balancer)
|
||||
# set this option to 'disable'. This is mutually exclusive with 'authentication: local'
|
||||
# Note: rdp connections over a gateway require TLS
|
||||
Tls: auto
|
||||
# gateway address advertised in the rdp files and browser
|
||||
GatewayAddress: localhost
|
||||
# port to listen on (change to 80 or equivalent if not using TLS)
|
||||
Port: 443
|
||||
# list of acceptable desktop hosts to connect to
|
||||
hosts:
|
||||
Hosts:
|
||||
- localhost:3389
|
||||
- my-{{ preferred_username }}-host:3389
|
||||
# Allow the user to connect to any host (insecure)
|
||||
- any
|
||||
# if true the server randomly selects a host to connect to
|
||||
roundRobin: false
|
||||
# valid options are:
|
||||
# - roundrobin, which selects a random host from the list (default)
|
||||
# - signed, a listed host specified in the signed query parameter
|
||||
# - unsigned, a listed host specified in the query parameter
|
||||
# - any, insecurely allow any host specified in the query parameter
|
||||
HostSelection: roundrobin
|
||||
# a random strings of at least 32 characters to secure cookies on the client
|
||||
# make sure to share this across the different pods
|
||||
sessionKey: thisisasessionkeyreplacethisjetzt
|
||||
sessionEncryptionKey: thisisasessionkeyreplacethisnunu!
|
||||
SessionKey: thisisasessionkeyreplacethisjetzt
|
||||
SessionEncryptionKey: thisisasessionkeyreplacethisnunu!
|
||||
# where to store session details. This can be either file or cookie (default: cookie)
|
||||
# if a file store is chosen, it is required to have clients 'keep state' to the rdpgw
|
||||
# instance they are connected to.
|
||||
SessionStore: cookie
|
||||
# tries to set the receive / send buffer of the connections to the client
|
||||
# in case of high latency high bandwidth the defaults set by the OS might
|
||||
# be to low for a good experience
|
||||
# ReceiveBuf: 12582912
|
||||
# SendBuf: 12582912
|
||||
# Open ID Connect specific settings
|
||||
openId:
|
||||
providerUrl: http://keycloak/auth/realms/test
|
||||
clientId: rdpgw
|
||||
clientSecret: your-secret
|
||||
# enabled / disabled capabilities
|
||||
caps:
|
||||
smartCardAuth: false
|
||||
tokenAuth: true
|
||||
OpenId:
|
||||
ProviderUrl: http://keycloak/auth/realms/test
|
||||
ClientId: rdpgw
|
||||
ClientSecret: your-secret
|
||||
# Kerberos:
|
||||
# Keytab: /etc/keytabs/rdpgw.keytab
|
||||
# Krb5conf: /etc/krb5.conf
|
||||
# enabled / disabled capabilities
|
||||
Caps:
|
||||
SmartCardAuth: false
|
||||
# required for openid connect
|
||||
TokenAuth: true
|
||||
# connection timeout in minutes, 0 is limitless
|
||||
idleTimeout: 10
|
||||
enablePrinter: true
|
||||
enablePort: true
|
||||
enablePnp: true
|
||||
enableDrive: true
|
||||
enableClipboard: true
|
||||
client:
|
||||
IdleTimeout: 10
|
||||
EnablePrinter: true
|
||||
EnablePort: true
|
||||
EnablePnp: true
|
||||
EnableDrive: true
|
||||
EnableClipboard: true
|
||||
Client:
|
||||
# template rdp file to use for clients
|
||||
# rdp file settings and their defaults see here:
|
||||
# https://docs.microsoft.com/en-us/windows-server/remote/remote-desktop-services/clients/rdp-files
|
||||
defaults: /etc/rdpgw/default.rdp
|
||||
# this is a go string templated with {{ username }} and {{ token }}
|
||||
# the example below uses the ASCII field separator to distinguish
|
||||
# between user and token
|
||||
usernameTemplate: "{{ username }}@bla.com\x1f{{ token }}"
|
||||
# rdp file settings see:
|
||||
# https://docs.microsoft.com/en-us/windows-server/remote/remote-desktop-services/clients/rdp-files
|
||||
networkAutoDetect: 0
|
||||
bandwidthAutoDetect: 1
|
||||
ConnectionType: 6
|
||||
UsernameTemplate: "{{ username }}@bla.com\x1f{{ token }}"
|
||||
# If true puts splits "user@domain.com" into the user and domain component so that
|
||||
# domain gets set in the rdp file and the domain name is stripped from the username
|
||||
SplitUserDomain: false
|
||||
security:
|
||||
# a random string of at least 32 characters to secure cookies on the client
|
||||
# If true, removes "username" (and "domain" if SplitUserDomain is true) from RDP file.
|
||||
# NoUsername: true
|
||||
Security:
|
||||
# a random string of 32 characters to secure cookies on the client
|
||||
# make sure to share this amongst different pods
|
||||
PAATokenSigningKey: thisisasessionkeyreplacethisjetzt
|
||||
# PAATokenEncryptionKey: thisisasessionkeyreplacethisjetzt
|
||||
# a random string of 32 characters to secure cookies on the client
|
||||
UserTokenEncryptionKey: thisisasessionkeyreplacethisjetzt
|
||||
# Signing makes the token bigger and we are limited to 511 characters
|
||||
# UserTokenSigningKey: thisisasessionkeyreplacethisjetzt
|
||||
# if you want to enable token generation for the user
|
||||
# if true the username will be set to a jwt with the username embedded into it
|
||||
EnableUserToken: true
|
||||
@ -101,17 +344,49 @@ security:
|
||||
# connection is opened.
|
||||
VerifyClientIp: true
|
||||
```
|
||||
|
||||
## How to build & install
|
||||
|
||||
__NOTE__: a [docker image](https://hub.docker.com/r/bolkedebruin/rdpgw/) is available on docker hub, which removes the need for building and installing go.
|
||||
|
||||
Ensure that you have `make` (comes with standard build tools, like `build-essential` on Debian), `go` (version 1.19 or above), and development files for PAM (`libpam0g-dev` on Debian) installed.
|
||||
|
||||
Then clone the repo and issues the following.
|
||||
|
||||
```bash
|
||||
cd rdpgw
|
||||
make
|
||||
make install
|
||||
```
|
||||
|
||||
## Testing locally
|
||||
A convenience docker-compose allows you to test the RDPGW locally. It uses [Keycloak](http://www.keycloak.org)
|
||||
and [xrdp](http://www.xrdp.org) and exposes it services on port 443. You will need to allow your browser
|
||||
and [xrdp](http://www.xrdp.org) and exposes it services on port 9443. You will need to allow your browser
|
||||
to connect to localhost with and self signed security certificate. For chrome set `chrome://flags/#allow-insecure-localhost`.
|
||||
The username to login to both Keycloak and xrdp is `admin` as is the password.
|
||||
|
||||
__NOTE__: The redirecting relies on DNS. Make sure to add ``127.0.0.1 keycloak`` to your `/etc/hosts` file to ensure
|
||||
that the redirect works.
|
||||
|
||||
__NOTE__: The local testing environment uses a self signed certificate. This works for MAC clients, but not for Windows.
|
||||
If you want to test it on Windows you will need to provide a valid certificate.
|
||||
|
||||
```bash
|
||||
# with open id
|
||||
cd dev/docker
|
||||
docker-compose build
|
||||
docker-compose up
|
||||
docker-compose -f docker-compose.yml up
|
||||
|
||||
# or for arm64 with open id
|
||||
docker-compose -f docker-compose-arm64.yml up
|
||||
|
||||
# or for local or pam
|
||||
docker-compose -f docker-compose-local.yml up
|
||||
```
|
||||
|
||||
You can then connect to the gateway at `https://localhost:9443/connect` for the OpenID connect flavors which will start
|
||||
the authentication flow. Or you can connect directly with the gateway set and the host set to ``xrdp`` if using the ``local``
|
||||
flavor. You can login with 'admin/admin'. The RDP file will download and you can open it with a remote
|
||||
desktop client. Also for logging in 'admin/admin' will work.
|
||||
|
||||
## Use
|
||||
Point your browser to `https://your-gateway/connect`. After authentication
|
||||
@ -127,11 +402,154 @@ It will return 200 OK with the decrypted token.
|
||||
|
||||
In this way you can integrate, for example, it with [pam-jwt](https://github.com/bolkedebruin/pam-jwt).
|
||||
|
||||
## Client Caveats
|
||||
The several clients that Microsoft provides come with their own caveats.
|
||||
The most important one is that the default client on Windows ``mstsc`` does
|
||||
not support basic authentication. This means you need to use either OpenID Connect,
|
||||
Kerberos or ntlm authentication.
|
||||
|
||||
In addition to that, ``mstsc``, when configuring a gateway directly in the client requires
|
||||
you to either:
|
||||
* "save the credentials" for the gateway
|
||||
* or specify a (random) domain name in the username field (e.g. ``.\username``) when prompted for the gateway credentials,
|
||||
|
||||
otherwise the client will not connect at all (it won't send any packages to the gateway) and it will keep on asking for new credentials.
|
||||
|
||||
Finally, ``mstsc`` requires a valid certificate on the gateway.
|
||||
|
||||
The Microsoft Remote Desktop Client from the Microsoft Store does not have these issues,
|
||||
but it requires that the username and password used for authentication are the same for
|
||||
both the gateway and the RDP host.
|
||||
|
||||
The Microsoft Remote Desktop Client for Mac does not have these issues and is the most flexible.
|
||||
It supports basic authentication, OpenID Connect and Kerberos and can use different credentials
|
||||
|
||||
The official Microsoft IOS and Android clients seem also more flexible.
|
||||
|
||||
Third party clients like [FreeRDP](https://www.freerdp.com) might also provide more
|
||||
flexibility.
|
||||
|
||||
## TODO
|
||||
* Integrate Open Policy Agent
|
||||
* Integrate GOKRB5
|
||||
* Integrate uber-go/zap
|
||||
* Research: TLS defragmentation
|
||||
* Improve Web Interface
|
||||
|
||||
# Acknowledgements
|
||||
* This product includes software developed by the Thomson Reuters Global Resources. ([go-ntlm](https://github.com/m7913d/go-ntlm) - BSD-4 License)
|
||||
|
||||
# RDPGW 认证 API 服务器
|
||||
|
||||
这是一个用于 RDPGW(远程桌面网关)的认证 API 服务器,提供用户验证和密码获取功能。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 支持用户名和密码验证(verify 模式)
|
||||
- 支持密码检索功能(getpassword 模式),用于 NTLM 认证
|
||||
- 支持 GET 和 POST 请求方式
|
||||
- 可通过配置文件自定义设置
|
||||
|
||||
## 安装
|
||||
|
||||
确保已安装 Node.js v10 或更高版本,然后执行:
|
||||
|
||||
```bash
|
||||
npm install express
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
配置文件为 `config.json`,包含以下选项:
|
||||
|
||||
```json
|
||||
{
|
||||
"port": 3000,
|
||||
"apiPath": "/api/checkperm",
|
||||
"users": {
|
||||
"testuser": "testpassword",
|
||||
"admin": "adminpass"
|
||||
},
|
||||
"logger": {
|
||||
"level": "info",
|
||||
"logToFile": false,
|
||||
"logFile": "server.log"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 配置选项说明
|
||||
|
||||
- `port`: 服务器监听端口
|
||||
- `apiPath`: API 路径
|
||||
- `users`: 用户名和密码字典
|
||||
- `logger`: 日志配置
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 启动服务器
|
||||
|
||||
```bash
|
||||
node index.js
|
||||
```
|
||||
|
||||
### API 请求示例
|
||||
|
||||
#### 验证模式 (verify)
|
||||
|
||||
**GET 请求**:
|
||||
```
|
||||
http://localhost:3000/api/checkperm?username=testuser&password=testpassword&mode=verify
|
||||
```
|
||||
|
||||
**POST 请求**:
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/checkperm \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"testuser", "password":"testpassword", "mode":"verify"}'
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"user": "testuser"
|
||||
}
|
||||
```
|
||||
|
||||
#### 密码获取模式 (getpassword)
|
||||
|
||||
**GET 请求**:
|
||||
```
|
||||
http://localhost:3000/api/checkperm?username=testuser&mode=getpassword
|
||||
```
|
||||
|
||||
**POST 请求**:
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/checkperm \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"testuser", "mode":"getpassword"}'
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"password": "testpassword"
|
||||
}
|
||||
```
|
||||
|
||||
## RDPGW 集成
|
||||
|
||||
在 RDPGW 配置中添加以下内容:
|
||||
|
||||
```yaml
|
||||
ntlm_api:
|
||||
enable: true
|
||||
server: http://localhost:3000
|
||||
path: /api/checkperm
|
||||
mode: getpassword
|
||||
```
|
||||
|
||||
## 安全建议
|
||||
|
||||
- 在生产环境中,请使用 HTTPS 而非 HTTP
|
||||
- 限制 API 服务器的访问权限
|
||||
- 定期更换密码并审查访问日志
|
||||
- 考虑实现 IP 访问限制和请求频率限制
|
||||
|
||||
15
UPGRADING.md
Normal file
15
UPGRADING.md
Normal file
@ -0,0 +1,15 @@
|
||||
# Upgrading from 1.X to 2.0
|
||||
|
||||
In 2.0 the options for configuring client side RDP settings have been removed in favor of template file.
|
||||
The template file is a RDP file that is used as a template for the connection. The template file is parsed
|
||||
and a few settings are replaced to ensure the client can connect to the server and the correct domain is used.
|
||||
|
||||
The format of the template file is as follows:
|
||||
|
||||
```
|
||||
# <setting>:<type i or s>:<value>
|
||||
domain:s:testdomain
|
||||
connection type:i:2
|
||||
```
|
||||
|
||||
The filename is set under `client > defaults`.
|
||||
219
api/web.go
219
api/web.go
@ -1,219 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/patrickmn/go-cache"
|
||||
"golang.org/x/oauth2"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
RdpGwSession = "RDPGWSESSION"
|
||||
MaxAge = 120
|
||||
)
|
||||
|
||||
type TokenGeneratorFunc func(context.Context, string, string) (string, error)
|
||||
type UserTokenGeneratorFunc func(context.Context, string) (string, error)
|
||||
|
||||
type Config struct {
|
||||
SessionKey []byte
|
||||
SessionEncryptionKey []byte
|
||||
PAATokenGenerator TokenGeneratorFunc
|
||||
UserTokenGenerator UserTokenGeneratorFunc
|
||||
EnableUserToken bool
|
||||
OAuth2Config *oauth2.Config
|
||||
store *sessions.CookieStore
|
||||
OIDCTokenVerifier *oidc.IDTokenVerifier
|
||||
stateStore *cache.Cache
|
||||
Hosts []string
|
||||
GatewayAddress string
|
||||
UsernameTemplate string
|
||||
NetworkAutoDetect int
|
||||
BandwidthAutoDetect int
|
||||
ConnectionType int
|
||||
SplitUserDomain bool
|
||||
DefaultDomain string
|
||||
}
|
||||
|
||||
func (c *Config) NewApi() {
|
||||
if len(c.SessionKey) < 32 {
|
||||
log.Fatal("Session key too small")
|
||||
}
|
||||
if len(c.Hosts) < 1 {
|
||||
log.Fatal("Not enough hosts to connect to specified")
|
||||
}
|
||||
c.store = sessions.NewCookieStore(c.SessionKey, c.SessionEncryptionKey)
|
||||
c.stateStore = cache.New(time.Minute*2, 5*time.Minute)
|
||||
}
|
||||
|
||||
func (c *Config) HandleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
state := r.URL.Query().Get("state")
|
||||
s, found := c.stateStore.Get(state)
|
||||
if !found {
|
||||
http.Error(w, "unknown state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
url := s.(string)
|
||||
|
||||
ctx := context.Background()
|
||||
oauth2Token, err := c.OAuth2Config.Exchange(ctx, r.URL.Query().Get("code"))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to exchange token: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
http.Error(w, "No id_token field in oauth2 token.", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
idToken, err := c.OIDCTokenVerifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to verify ID Token: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
resp := struct {
|
||||
OAuth2Token *oauth2.Token
|
||||
IDTokenClaims *json.RawMessage // ID Token payload is just JSON.
|
||||
}{oauth2Token, new(json.RawMessage)}
|
||||
|
||||
if err := idToken.Claims(&resp.IDTokenClaims); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(*resp.IDTokenClaims, &data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
session, err := c.store.Get(r, RdpGwSession)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
session.Options.MaxAge = MaxAge
|
||||
session.Values["preferred_username"] = data["preferred_username"]
|
||||
session.Values["authenticated"] = true
|
||||
session.Values["access_token"] = oauth2Token.AccessToken
|
||||
|
||||
if err = session.Save(r, w); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
http.Redirect(w, r, url, http.StatusFound)
|
||||
}
|
||||
|
||||
func (c *Config) Authenticated(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session, err := c.store.Get(r, RdpGwSession)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
found := session.Values["authenticated"]
|
||||
if found == nil || !found.(bool) {
|
||||
seed := make([]byte, 16)
|
||||
rand.Read(seed)
|
||||
state := hex.EncodeToString(seed)
|
||||
c.stateStore.Set(state, r.RequestURI, cache.DefaultExpiration)
|
||||
http.Redirect(w, r, c.OAuth2Config.AuthCodeURL(state), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), "preferred_username", session.Values["preferred_username"])
|
||||
ctx = context.WithValue(ctx, "access_token", session.Values["access_token"])
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Config) HandleDownload(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
userName, ok := ctx.Value("preferred_username").(string)
|
||||
|
||||
if !ok {
|
||||
log.Printf("preferred_username not found in context")
|
||||
http.Error(w, errors.New("cannot find session or user").Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// do a round robin selection for now
|
||||
rand.Seed(time.Now().Unix())
|
||||
host := c.Hosts[rand.Intn(len(c.Hosts))]
|
||||
host = strings.Replace(host, "{{ preferred_username }}", userName, 1)
|
||||
|
||||
// split the username into user and domain
|
||||
var user = userName
|
||||
var domain = c.DefaultDomain
|
||||
if c.SplitUserDomain {
|
||||
creds := strings.SplitN(userName, "@", 2)
|
||||
user = creds[0]
|
||||
if len(creds) > 1 {
|
||||
domain = creds[1]
|
||||
}
|
||||
}
|
||||
|
||||
render := user
|
||||
if c.UsernameTemplate != "" {
|
||||
render = fmt.Sprintf(c.UsernameTemplate)
|
||||
render = strings.Replace(render, "{{ username }}", user, 1)
|
||||
if c.UsernameTemplate == render {
|
||||
log.Printf("Invalid username template. %s == %s", c.UsernameTemplate, user)
|
||||
http.Error(w, errors.New("invalid server configuration").Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
token, err := c.PAATokenGenerator(ctx, user, host)
|
||||
if err != nil {
|
||||
log.Printf("Cannot generate PAA token for user %s due to %s", user, err)
|
||||
http.Error(w, errors.New("unable to generate gateway credentials").Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if c.EnableUserToken {
|
||||
userToken, err := c.UserTokenGenerator(ctx, user)
|
||||
if err != nil {
|
||||
log.Printf("Cannot generate token for user %s due to %s", user, err)
|
||||
http.Error(w, errors.New("unable to generate gateway credentials").Error(), http.StatusInternalServerError)
|
||||
}
|
||||
render = strings.Replace(render, "{{ token }}", userToken, 1)
|
||||
}
|
||||
|
||||
// authenticated
|
||||
seed := make([]byte, 16)
|
||||
rand.Read(seed)
|
||||
fn := hex.EncodeToString(seed) + ".rdp"
|
||||
|
||||
w.Header().Set("Content-Disposition", "attachment; filename="+fn)
|
||||
w.Header().Set("Content-Type", "application/x-rdp")
|
||||
data := "full address:s:"+host+"\r\n"+
|
||||
"gatewayhostname:s:"+c.GatewayAddress+"\r\n"+
|
||||
"gatewaycredentialssource:i:5\r\n"+
|
||||
"gatewayusagemethod:i:1\r\n"+
|
||||
"gatewayprofileusagemethod:i:1\r\n"+
|
||||
"gatewayaccesstoken:s:"+token+"\r\n"+
|
||||
"networkautodetect:i:"+strconv.Itoa(c.NetworkAutoDetect)+"\r\n"+
|
||||
"bandwidthautodetect:i:"+strconv.Itoa(c.BandwidthAutoDetect)+"\r\n"+
|
||||
"connection type:i:"+strconv.Itoa(c.ConnectionType)+"\r\n"+
|
||||
"username:s:"+render+"\r\n"+
|
||||
"domain:s:"+domain+"\r\n"+
|
||||
"bitmapcachesize:i:32000\r\n"
|
||||
|
||||
http.ServeContent(w, r, fn, time.Now(), strings.NewReader(data))
|
||||
}
|
||||
152
cmd/auth/auth.go
Normal file
152
cmd/auth/auth.go
Normal file
@ -0,0 +1,152 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/auth/config"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/auth/database"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/auth/ntlm"
|
||||
"github.com/bolkedebruin/rdpgw/shared/auth"
|
||||
"github.com/msteinert/pam/v2"
|
||||
"github.com/thought-machine/go-flags"
|
||||
"google.golang.org/grpc"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
const (
|
||||
protocol = "unix"
|
||||
)
|
||||
|
||||
var opts struct {
|
||||
ServiceName string `short:"n" long:"name" default:"rdpgw" description:"the PAM service name to use"`
|
||||
SocketAddr string `short:"s" long:"socket" default:"/tmp/rdpgw-auth.sock" description:"the location of the socket"`
|
||||
ConfigFile string `short:"c" long:"conf" default:"rdpgw-auth.yaml" description:"users config file for NTLM (yaml)"`
|
||||
}
|
||||
|
||||
type AuthServiceImpl struct {
|
||||
auth.UnimplementedAuthenticateServer
|
||||
|
||||
serviceName string
|
||||
ntlm *ntlm.NTLMAuth
|
||||
}
|
||||
|
||||
var conf config.Configuration
|
||||
var _ auth.AuthenticateServer = (*AuthServiceImpl)(nil)
|
||||
|
||||
func NewAuthService(serviceName string, database database.Database) auth.AuthenticateServer {
|
||||
s := &AuthServiceImpl{
|
||||
serviceName: serviceName,
|
||||
ntlm: ntlm.NewNTLMAuth(database),
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *AuthServiceImpl) Authenticate(ctx context.Context, message *auth.UserPass) (*auth.AuthResponse, error) {
|
||||
t, err := pam.StartFunc(s.serviceName, message.Username, func(s pam.Style, msg string) (string, error) {
|
||||
switch s {
|
||||
case pam.PromptEchoOff:
|
||||
return message.Password, nil
|
||||
case pam.PromptEchoOn, pam.ErrorMsg, pam.TextInfo:
|
||||
return "", nil
|
||||
}
|
||||
return "", errors.New("unrecognized PAM message style")
|
||||
})
|
||||
|
||||
r := &auth.AuthResponse{}
|
||||
r.Authenticated = false
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error authenticating user: %s due to: %s", message.Username, err)
|
||||
r.Error = err.Error()
|
||||
return r, err
|
||||
}
|
||||
defer func() {
|
||||
err := t.End()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "end: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
if err = t.Authenticate(0); err != nil {
|
||||
log.Printf("Authentication for user: %s failed due to: %s", message.Username, err)
|
||||
r.Error = err.Error()
|
||||
return r, nil
|
||||
}
|
||||
|
||||
if err = t.AcctMgmt(0); err != nil {
|
||||
log.Printf("Account authorization for user: %s failed due to %s", message.Username, err)
|
||||
r.Error = err.Error()
|
||||
return r, nil
|
||||
}
|
||||
|
||||
log.Printf("User: %s authenticated", message.Username)
|
||||
r.Authenticated = true
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *AuthServiceImpl) NTLM(ctx context.Context, message *auth.NtlmRequest) (*auth.NtlmResponse, error) {
|
||||
r, err := s.ntlm.Authenticate(message)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[%s] NTLM failed: %s", message.Session, err)
|
||||
} else if r.Authenticated {
|
||||
log.Printf("[%s] User: %s authenticated using NTLM", message.Session, r.Username)
|
||||
} else if r.NtlmMessage != "" {
|
||||
log.Printf("[%s] Sending NTLM challenge", message.Session)
|
||||
}
|
||||
|
||||
return r, err
|
||||
}
|
||||
|
||||
func main() {
|
||||
_, err := flags.Parse(&opts)
|
||||
if err != nil {
|
||||
var fErr *flags.Error
|
||||
if errors.As(err, &fErr) {
|
||||
if fErr.Type == flags.ErrHelp {
|
||||
fmt.Printf("Acknowledgements:\n")
|
||||
fmt.Printf(" - This product includes software developed by the Thomson Reuters Global Resources. (go-ntlm - https://github.com/m7913d/go-ntlm - BSD-4 License)\n")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
conf = config.Load(opts.ConfigFile)
|
||||
|
||||
log.Printf("Starting auth server on %s", opts.SocketAddr)
|
||||
cleanup := func() {
|
||||
if _, err := os.Stat(opts.SocketAddr); err == nil {
|
||||
if err := os.RemoveAll(opts.SocketAddr); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
cleanup()
|
||||
|
||||
oldUmask := syscall.Umask(0)
|
||||
listener, err := net.Listen(protocol, opts.SocketAddr)
|
||||
syscall.Umask(oldUmask)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
server := grpc.NewServer()
|
||||
|
||||
// 根据配置选择使用API认证或本地配置认证
|
||||
var db database.Database
|
||||
if conf.PXVDI.Enabled && conf.PXVDI.ApiUrl != "" {
|
||||
log.Printf("Using API authentication, API URL: %s", conf.PXVDI.ApiUrl)
|
||||
db = database.NewApiDb(conf.PXVDI.ApiUrl, conf.PXVDI.ApiKey)
|
||||
} else {
|
||||
log.Printf("Using local configuration file authentication")
|
||||
db = database.NewConfig(conf.Users)
|
||||
}
|
||||
|
||||
service := NewAuthService(opts.ServiceName, db)
|
||||
auth.RegisterAuthenticateServer(server, service)
|
||||
server.Serve(listener)
|
||||
}
|
||||
50
cmd/auth/config/configuration.go
Normal file
50
cmd/auth/config/configuration.go
Normal file
@ -0,0 +1,50 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/knadh/koanf/parsers/yaml"
|
||||
"github.com/knadh/koanf/providers/confmap"
|
||||
"github.com/knadh/koanf/providers/file"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Configuration struct {
|
||||
Users []UserConfig `koanf:"users"`
|
||||
PXVDI PXVDIConfig `koanf:"pxvdi"`
|
||||
}
|
||||
|
||||
type PXVDIConfig struct {
|
||||
Enabled bool `koanf:"enabled"`
|
||||
ApiUrl string `koanf:"apiurl"`
|
||||
ApiKey string `koanf:"apikey"`
|
||||
}
|
||||
|
||||
type UserConfig struct {
|
||||
Username string `koanf:"username"`
|
||||
Password string `koanf:"password"`
|
||||
}
|
||||
|
||||
var Conf Configuration
|
||||
|
||||
func Load(configFile string) Configuration {
|
||||
|
||||
var k = koanf.New(".")
|
||||
|
||||
k.Load(confmap.Provider(map[string]interface{}{}, "."), nil)
|
||||
|
||||
if _, err := os.Stat(configFile); os.IsNotExist(err) {
|
||||
log.Printf("Config file %s not found, skipping config file", configFile)
|
||||
} else {
|
||||
if err := k.Load(file.Provider(configFile), yaml.Parser()); err != nil {
|
||||
log.Fatalf("Error loading config from file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
koanfTag := koanf.UnmarshalConf{Tag: "koanf"}
|
||||
k.UnmarshalWithConf("Users", &Conf.Users, koanfTag)
|
||||
k.UnmarshalWithConf("PXVDI", &Conf.PXVDI, koanfTag)
|
||||
|
||||
return Conf
|
||||
|
||||
}
|
||||
181
cmd/auth/database/apidb.go
Normal file
181
cmd/auth/database/apidb.go
Normal file
@ -0,0 +1,181 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// ApiDb 结构实现Database接口,通过API验证用户凭据
|
||||
type ApiDb struct {
|
||||
ApiUrl string // API URL模板,使用{username}和{password}作为占位符
|
||||
ApiKey string // API密钥
|
||||
}
|
||||
|
||||
// NewApiDb 创建一个新的ApiDb实例
|
||||
func NewApiDb(apiUrl string, apiKey string) *ApiDb {
|
||||
return &ApiDb{
|
||||
ApiUrl: apiUrl,
|
||||
ApiKey: apiKey,
|
||||
}
|
||||
}
|
||||
|
||||
// GetPassword 从API获取用户密码
|
||||
// 这个方法会调用API来获取用户的实际密码,用于NTLM认证
|
||||
func (a *ApiDb) GetPassword(username string) string {
|
||||
log.Printf("Getpassword: %s", username)
|
||||
|
||||
// 如果用户名为空,直接返回失败
|
||||
if username == "" {
|
||||
log.Printf("API password retrieval failed: empty username")
|
||||
return ""
|
||||
}
|
||||
|
||||
// 构建API URL
|
||||
fullUrl := fmt.Sprintf("%s/api/custom/public/ntlmcheck", a.ApiUrl)
|
||||
|
||||
// 构建POST请求体
|
||||
requestData := map[string]string{
|
||||
"user": username,
|
||||
"apikey": a.ApiKey,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(requestData)
|
||||
if err != nil {
|
||||
log.Printf("Failed to marshal request data: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
log.Printf("Sending API password POST request to: %s", fullUrl)
|
||||
|
||||
// 创建自定义HTTP客户端,跳过SSL证书验证
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client := &http.Client{Transport: tr}
|
||||
|
||||
// 发送POST请求到API,使用不验证SSL证书的客户端
|
||||
resp, err := client.Post(fullUrl, "application/json", bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
log.Printf("API password retrieval error: %v", err)
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查HTTP状态码
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.Printf("API password retrieval failed with status: %d", resp.StatusCode)
|
||||
return ""
|
||||
}
|
||||
|
||||
// 读取响应内容
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Printf("Failed to read API response: %v", err)
|
||||
return ""
|
||||
}
|
||||
log.Printf("API password response received")
|
||||
|
||||
// 解析响应 - 适应新的响应格式
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Pass string `json:"pass"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
err = json.NewDecoder(bytes.NewReader(body)).Decode(&result)
|
||||
if err != nil {
|
||||
log.Printf("Failed to parse API response: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
// 检查响应内容
|
||||
if !result.Success || result.Data.Pass == "" {
|
||||
log.Printf("API did not return a valid password for user: %s", username)
|
||||
return ""
|
||||
}
|
||||
|
||||
log.Printf("API password retrieval successful for user: %s", username)
|
||||
return result.Data.Pass
|
||||
}
|
||||
|
||||
// VerifyCredentials 验证用户凭据
|
||||
func (a *ApiDb) VerifyCredentials(username, password string) bool {
|
||||
// 如果用户名为空,直接返回失败
|
||||
if username == "" {
|
||||
log.Printf("API verification failed: empty username")
|
||||
return false
|
||||
}
|
||||
|
||||
// 构建API URL,替换占位符
|
||||
apiUrl := a.ApiUrl
|
||||
|
||||
// 构建完整的URL,包括查询参数
|
||||
var fullUrl string
|
||||
if password == "" {
|
||||
// NTLM场景下直接将用户名传递给API
|
||||
// 这种情况下,后端API应当能够独立验证用户
|
||||
fullUrl = fmt.Sprintf("%s?username=%s&mode=verify",
|
||||
apiUrl, url.QueryEscape(username))
|
||||
log.Printf("Verifying NTLM user via API: %s", username)
|
||||
} else {
|
||||
// 常规场景
|
||||
fullUrl = fmt.Sprintf("%s?username=%s&password=%s&mode=verify",
|
||||
apiUrl, url.QueryEscape(username), url.QueryEscape(password))
|
||||
}
|
||||
|
||||
log.Printf("Sending API verification request to: %s", fullUrl)
|
||||
|
||||
// 创建自定义HTTP客户端,跳过SSL证书验证
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client := &http.Client{Transport: tr}
|
||||
|
||||
// 发送请求到API,使用不验证SSL证书的客户端
|
||||
resp, err := client.Get(fullUrl)
|
||||
if err != nil {
|
||||
log.Printf("API verification error: %v", err)
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查HTTP状态码
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.Printf("API verification failed with status: %d", resp.StatusCode)
|
||||
return false
|
||||
}
|
||||
|
||||
// 读取响应内容
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Printf("Failed to read API response: %v", err)
|
||||
return false
|
||||
}
|
||||
log.Printf("API response: %s", string(body))
|
||||
|
||||
// 解析响应
|
||||
var result map[string]interface{}
|
||||
err = json.NewDecoder(bytes.NewReader(body)).Decode(&result)
|
||||
if err != nil {
|
||||
log.Printf("Failed to parse API response: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查响应内容
|
||||
// 如果响应包含"success",则验证成功
|
||||
if status, ok := result["status"].(string); ok && status == "success" {
|
||||
log.Printf("API verification successful for user: %s", username)
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果没有找到预期的成功标识,则验证失败
|
||||
log.Printf("API verification failed for user: %s", username)
|
||||
return false
|
||||
}
|
||||
25
cmd/auth/database/config.go
Executable file
25
cmd/auth/database/config.go
Executable file
@ -0,0 +1,25 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"github.com/bolkedebruin/rdpgw/cmd/auth/config"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
users map[string]config.UserConfig
|
||||
}
|
||||
|
||||
func NewConfig(users []config.UserConfig) *Config {
|
||||
usersMap := map[string]config.UserConfig{}
|
||||
|
||||
for _, user := range users {
|
||||
usersMap[user.Username] = user
|
||||
}
|
||||
|
||||
return &Config{
|
||||
users: usersMap,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) GetPassword (username string) string {
|
||||
return c.users[username].Password
|
||||
}
|
||||
43
cmd/auth/database/config_test.go
Normal file
43
cmd/auth/database/config_test.go
Normal file
@ -0,0 +1,43 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"github.com/bolkedebruin/rdpgw/cmd/auth/config"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func createTestDatabase () (Database) {
|
||||
var users = []config.UserConfig{}
|
||||
|
||||
user1 := config.UserConfig{}
|
||||
user1.Username = "my_username"
|
||||
user1.Password = "my_password"
|
||||
users = append(users, user1)
|
||||
|
||||
user2 := config.UserConfig{}
|
||||
user2.Username = "my_username2"
|
||||
user2.Password = "my_password2"
|
||||
users = append(users, user2)
|
||||
|
||||
config := NewConfig(users)
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
func TestDatabaseConfigValidUsername(t *testing.T) {
|
||||
database := createTestDatabase()
|
||||
|
||||
if database.GetPassword("my_username") != "my_password" {
|
||||
t.Fatalf("Wrong password returned")
|
||||
}
|
||||
if database.GetPassword("my_username2") != "my_password2" {
|
||||
t.Fatalf("Wrong password returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseInvalidUsername(t *testing.T) {
|
||||
database := createTestDatabase()
|
||||
|
||||
if database.GetPassword("my_invalid_username") != "" {
|
||||
t.Fatalf("Non empty password returned for invalid username")
|
||||
}
|
||||
}
|
||||
5
cmd/auth/database/database.go
Executable file
5
cmd/auth/database/database.go
Executable file
@ -0,0 +1,5 @@
|
||||
package database
|
||||
|
||||
type Database interface {
|
||||
GetPassword (username string) string
|
||||
}
|
||||
164
cmd/auth/ntlm/ntlm.go
Normal file
164
cmd/auth/ntlm/ntlm.go
Normal file
@ -0,0 +1,164 @@
|
||||
package ntlm
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/auth/database"
|
||||
"github.com/bolkedebruin/rdpgw/shared/auth"
|
||||
"github.com/patrickmn/go-cache"
|
||||
"github.com/m7913d/go-ntlm/ntlm"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
cacheExpiration = time.Minute
|
||||
cleanupInterval = time.Minute * 5
|
||||
)
|
||||
|
||||
type NTLMAuth struct {
|
||||
contextCache *cache.Cache
|
||||
|
||||
// Information about the server, returned to the client during authentication
|
||||
ServerName string // e.g. EXAMPLE1
|
||||
DomainName string // e.g. EXAMPLE
|
||||
DnsServerName string // e.g. example1.example.com
|
||||
DnsDomainName string // e.g. example.com
|
||||
DnsTreeName string // e.g. example.com
|
||||
|
||||
Database database.Database
|
||||
}
|
||||
|
||||
func NewNTLMAuth (database database.Database) (*NTLMAuth) {
|
||||
return &NTLMAuth{
|
||||
contextCache: cache.New(cacheExpiration, cleanupInterval),
|
||||
Database: database,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *NTLMAuth) Authenticate(message *auth.NtlmRequest) (*auth.NtlmResponse, error) {
|
||||
r := &auth.NtlmResponse{}
|
||||
r.Authenticated = false
|
||||
|
||||
if message.Session == "" {
|
||||
return r, errors.New("Invalid (empty) session specified")
|
||||
}
|
||||
|
||||
if message.NtlmMessage == "" {
|
||||
return r, errors.New("Empty NTLM message specified")
|
||||
}
|
||||
|
||||
c := h.getContext(message.Session)
|
||||
err := c.Authenticate(message.NtlmMessage, r)
|
||||
|
||||
if err != nil || r.Authenticated {
|
||||
h.removeContext(message.Session)
|
||||
}
|
||||
|
||||
return r, err
|
||||
}
|
||||
|
||||
func (h *NTLMAuth) getContext (session string) (*ntlmContext) {
|
||||
if c_, found := h.contextCache.Get(session); found {
|
||||
if c, ok := c_.(*ntlmContext); ok {
|
||||
return c
|
||||
}
|
||||
}
|
||||
c := new(ntlmContext)
|
||||
c.h = h
|
||||
h.contextCache.Set(session, c, cache.DefaultExpiration)
|
||||
return c
|
||||
}
|
||||
|
||||
func (h *NTLMAuth) removeContext (session string) {
|
||||
h.contextCache.Delete(session)
|
||||
}
|
||||
|
||||
type ntlmContext struct {
|
||||
session ntlm.ServerSession
|
||||
h *NTLMAuth
|
||||
}
|
||||
|
||||
func (c *ntlmContext) Authenticate(authorisationEncoded string, r *auth.NtlmResponse) (error) {
|
||||
authorisation, err := base64.StdEncoding.DecodeString(authorisationEncoded)
|
||||
if err != nil {
|
||||
return errors.New(fmt.Sprintf("Failed to decode NTLM Authorisation header: %s", err))
|
||||
}
|
||||
|
||||
nm, err := ntlm.ParseNegotiateMessage(authorisation)
|
||||
if err == nil {
|
||||
return c.negotiate(nm, r)
|
||||
}
|
||||
if (nm != nil && nm.MessageType == 1) {
|
||||
return errors.New(fmt.Sprintf("Failed to parse NTLM Authorisation header: %s", err))
|
||||
} else if c.session == nil {
|
||||
return errors.New(fmt.Sprintf("New NTLM auth sequence should start with negotioate request"))
|
||||
}
|
||||
|
||||
am, err := ntlm.ParseAuthenticateMessage(authorisation, 2)
|
||||
if err == nil {
|
||||
return c.authenticate(am, r)
|
||||
}
|
||||
|
||||
return errors.New(fmt.Sprintf("Failed to parse NTLM Authorisation header: %s", err))
|
||||
}
|
||||
|
||||
func (c *ntlmContext) negotiate(nm *ntlm.NegotiateMessage, r *auth.NtlmResponse) (error) {
|
||||
session, err := ntlm.CreateServerSession(ntlm.Version2, ntlm.ConnectionOrientedMode)
|
||||
|
||||
if err != nil {
|
||||
c.session = nil;
|
||||
return errors.New(fmt.Sprintf("Failed to create NTLM server session: %s", err))
|
||||
}
|
||||
|
||||
c.session = session
|
||||
c.session.SetRequireNtHash(true)
|
||||
c.session.SetDomainName(c.h.DomainName)
|
||||
c.session.SetComputerName(c.h.ServerName)
|
||||
c.session.SetDnsDomainName(c.h.DnsDomainName)
|
||||
c.session.SetDnsComputerName(c.h.DnsServerName)
|
||||
c.session.SetDnsTreeName(c.h.DnsTreeName)
|
||||
|
||||
err = c.session.ProcessNegotiateMessage(nm)
|
||||
if err != nil {
|
||||
return errors.New(fmt.Sprintf("Failed to process NTLM negotiate message: %s", err))
|
||||
}
|
||||
|
||||
cm, err := c.session.GenerateChallengeMessage()
|
||||
if err != nil {
|
||||
return errors.New(fmt.Sprintf("Failed to generate NTLM challenge message: %s", err))
|
||||
}
|
||||
|
||||
r.NtlmMessage = base64.StdEncoding.EncodeToString(cm.Bytes())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ntlmContext) authenticate(am *ntlm.AuthenticateMessage, r *auth.NtlmResponse) (error) {
|
||||
if c.session == nil {
|
||||
return errors.New(fmt.Sprintf("NTLM Authenticate requires active session: first call negotioate"))
|
||||
}
|
||||
|
||||
username := am.UserName.String()
|
||||
log.Printf("NTLM: Trying to validate user: %s", username)
|
||||
|
||||
password := c.h.Database.GetPassword(username)
|
||||
if password == "" {
|
||||
log.Printf("NTLM: unknown username specified: %s", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("NTLM: Successfully retrieved password for user: %s", username)
|
||||
c.session.SetUserInfo(username, password, "")
|
||||
|
||||
err := c.session.ProcessAuthenticateMessage(am)
|
||||
if err != nil {
|
||||
log.Printf("Failed to process NTLM authenticate message: %s", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
r.Authenticated = true
|
||||
r.Username = username
|
||||
log.Printf("NTLM: User %s authenticated successfully", username)
|
||||
return nil
|
||||
}
|
||||
168
cmd/auth/ntlm/ntlm_test.go
Normal file
168
cmd/auth/ntlm/ntlm_test.go
Normal file
@ -0,0 +1,168 @@
|
||||
package ntlm
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/auth/config"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/auth/database"
|
||||
"github.com/bolkedebruin/rdpgw/shared/auth"
|
||||
"github.com/m7913d/go-ntlm/ntlm"
|
||||
"testing"
|
||||
"log"
|
||||
)
|
||||
|
||||
func createTestDatabase () (database.Database) {
|
||||
user := config.UserConfig{}
|
||||
user.Username = "my_username"
|
||||
user.Password = "my_password"
|
||||
|
||||
var users = []config.UserConfig{}
|
||||
users = append(users, user)
|
||||
|
||||
config := database.NewConfig(users)
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
func TestNtlmValidCredentials(t *testing.T) {
|
||||
client := ntlm.V2ClientSession{}
|
||||
client.SetUserInfo("my_username", "my_password", "")
|
||||
|
||||
authenticateResponse := authenticate(t, &client)
|
||||
if !authenticateResponse.Authenticated {
|
||||
t.Errorf("Failed to authenticate")
|
||||
return
|
||||
}
|
||||
if authenticateResponse.Username != "my_username" {
|
||||
t.Errorf("Wrong username returned")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func TestNtlmInvalidPassword(t *testing.T) {
|
||||
client := ntlm.V2ClientSession{}
|
||||
client.SetUserInfo("my_username", "my_invalid_password", "")
|
||||
|
||||
authenticateResponse := authenticate(t, &client)
|
||||
if authenticateResponse.Authenticated {
|
||||
t.Errorf("Authenticated with wrong password")
|
||||
return
|
||||
}
|
||||
if authenticateResponse.Username != "" {
|
||||
t.Errorf("If authentication failed, no username should be returned")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func TestNtlmInvalidUsername(t *testing.T) {
|
||||
client := ntlm.V2ClientSession{}
|
||||
client.SetUserInfo("my_invalid_username", "my_password", "")
|
||||
|
||||
authenticateResponse := authenticate(t, &client)
|
||||
if authenticateResponse.Authenticated {
|
||||
t.Errorf("Authenticated with wrong password")
|
||||
return
|
||||
}
|
||||
if authenticateResponse.Username != "" {
|
||||
t.Errorf("If authentication failed, no username should be returned")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func authenticate(t *testing.T, client *ntlm.V2ClientSession) (*auth.NtlmResponse) {
|
||||
session := "X"
|
||||
database := createTestDatabase()
|
||||
|
||||
server := NewNTLMAuth(database)
|
||||
|
||||
negotiate, err := client.GenerateNegotiateMessage()
|
||||
if err != nil {
|
||||
t.Errorf("Could not generate negotiate message: %s", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
negotiateRequest := &auth.NtlmRequest{}
|
||||
negotiateRequest.Session = session
|
||||
negotiateRequest.NtlmMessage = base64.StdEncoding.EncodeToString(negotiate.Bytes())
|
||||
negotiateResponse, err := server.Authenticate(negotiateRequest)
|
||||
if err != nil {
|
||||
t.Errorf("Could not generate challenge message: %s", err)
|
||||
return nil
|
||||
}
|
||||
if negotiateResponse.Authenticated {
|
||||
t.Errorf("User should not be authenticated by after negotiate message")
|
||||
return nil
|
||||
}
|
||||
if negotiateResponse.NtlmMessage == "" {
|
||||
t.Errorf("Could not generate challenge message")
|
||||
return nil
|
||||
}
|
||||
|
||||
decodedChallenge, err := base64.StdEncoding.DecodeString(negotiateResponse.NtlmMessage)
|
||||
if err != nil {
|
||||
t.Errorf("Challenge should be base64 encoded: %s", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
challenge, err := ntlm.ParseChallengeMessage(decodedChallenge)
|
||||
if err != nil {
|
||||
t.Errorf("Invalid challenge message generated: %s", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
client.ProcessChallengeMessage(challenge)
|
||||
authenticate, err := client.GenerateAuthenticateMessage()
|
||||
if err != nil {
|
||||
t.Errorf("Could not generate authenticate message: %s", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
authenticateRequest := &auth.NtlmRequest{}
|
||||
authenticateRequest.Session = session
|
||||
authenticateRequest.NtlmMessage = base64.StdEncoding.EncodeToString(authenticate.Bytes())
|
||||
authenticateResponse, err := server.Authenticate(authenticateRequest)
|
||||
if err != nil {
|
||||
t.Errorf("Could not parse authenticate message: %s", err)
|
||||
return authenticateResponse
|
||||
}
|
||||
if authenticateResponse.NtlmMessage != "" {
|
||||
t.Errorf("Authenticate request should not generate a new NTLM message")
|
||||
return authenticateResponse
|
||||
}
|
||||
return authenticateResponse
|
||||
}
|
||||
|
||||
func TestInvalidBase64 (t *testing.T) {
|
||||
testInvalidDataBase(t, "X", "X") // not valid base64
|
||||
}
|
||||
|
||||
func TestInvalidData (t *testing.T) {
|
||||
testInvalidDataBase(t, "X", "XXXX") // valid base64
|
||||
}
|
||||
|
||||
func TestInvalidDataEmptyMessage (t *testing.T) {
|
||||
testInvalidDataBase(t, "X", "")
|
||||
}
|
||||
|
||||
func TestEmptySession (t *testing.T) {
|
||||
testInvalidDataBase(t, "", "XXXX")
|
||||
}
|
||||
|
||||
func testInvalidDataBase (t *testing.T, session string, data string) {
|
||||
database := createTestDatabase()
|
||||
server := NewNTLMAuth(database)
|
||||
|
||||
request := &auth.NtlmRequest{}
|
||||
request.Session = session
|
||||
request.NtlmMessage = data
|
||||
response, err := server.Authenticate(request)
|
||||
log.Printf("%s",err)
|
||||
if err == nil {
|
||||
t.Errorf("Invalid request should return an error")
|
||||
}
|
||||
if response.Authenticated {
|
||||
t.Errorf("User should not be authenticated using invalid data")
|
||||
}
|
||||
if response.NtlmMessage != "" {
|
||||
t.Errorf("No NTLM message should be generated for invalid data")
|
||||
}
|
||||
}
|
||||
280
cmd/rdpgw/config/configuration.go
Normal file
280
cmd/rdpgw/config/configuration.go
Normal file
@ -0,0 +1,280 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/security"
|
||||
"github.com/knadh/koanf/parsers/yaml"
|
||||
"github.com/knadh/koanf/providers/confmap"
|
||||
"github.com/knadh/koanf/providers/env"
|
||||
"github.com/knadh/koanf/providers/file"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
TlsDisable = "disable"
|
||||
TlsAuto = "auto"
|
||||
|
||||
HostSelectionSigned = "signed"
|
||||
HostSelectionRoundRobin = "roundrobin"
|
||||
|
||||
SessionStoreCookie = "cookie"
|
||||
SessionStoreFile = "file"
|
||||
|
||||
AuthenticationOpenId = "openid"
|
||||
AuthenticationBasic = "local"
|
||||
AuthenticationKerberos = "kerberos"
|
||||
)
|
||||
|
||||
type Configuration struct {
|
||||
Server ServerConfig `koanf:"server"`
|
||||
OpenId OpenIDConfig `koanf:"openid"`
|
||||
Kerberos KerberosConfig `koanf:"kerberos"`
|
||||
Caps RDGCapsConfig `koanf:"caps"`
|
||||
Security SecurityConfig `koanf:"security"`
|
||||
Client ClientConfig `koanf:"client"`
|
||||
PXVDI PXVDIConfig `koanf:"pxvdi"`
|
||||
}
|
||||
|
||||
type PXVDIConfig struct {
|
||||
Enabled bool `koanf:"enabled"`
|
||||
ApiUrl string `koanf:"apiurl"`
|
||||
ApiKey string `koanf:"apikey"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
GatewayAddress string `koanf:"gatewayaddress"`
|
||||
Port int `koanf:"port"`
|
||||
CertFile string `koanf:"certfile"`
|
||||
KeyFile string `koanf:"keyfile"`
|
||||
Hosts []string `koanf:"hosts"`
|
||||
HostSelection string `koanf:"hostselection"`
|
||||
SessionKey string `koanf:"sessionkey"`
|
||||
SessionEncryptionKey string `koanf:"sessionencryptionkey"`
|
||||
SessionStore string `koanf:"sessionstore"`
|
||||
MaxSessionLength int `koanf:"maxsessionlength"`
|
||||
SendBuf int `koanf:"sendbuf"`
|
||||
ReceiveBuf int `koanf:"receivebuf"`
|
||||
Tls string `koanf:"tls"`
|
||||
Authentication []string `koanf:"authentication"`
|
||||
AuthSocket string `koanf:"authsocket"`
|
||||
BasicAuthTimeout int `koanf:"basicauthtimeout"`
|
||||
}
|
||||
|
||||
type KerberosConfig struct {
|
||||
Keytab string `koanf:"keytab"`
|
||||
Krb5Conf string `koanf:"krb5conf"`
|
||||
}
|
||||
|
||||
type OpenIDConfig struct {
|
||||
ProviderUrl string `koanf:"providerurl"`
|
||||
ClientId string `koanf:"clientid"`
|
||||
ClientSecret string `koanf:"clientsecret"`
|
||||
}
|
||||
|
||||
type RDGCapsConfig struct {
|
||||
SmartCardAuth bool `koanf:"smartcardauth"`
|
||||
TokenAuth bool `koanf:"tokenauth"`
|
||||
IdleTimeout int `koanf:"idletimeout"`
|
||||
RedirectAll bool `koanf:"redirectall"`
|
||||
DisableRedirect bool `koanf:"disableredirect"`
|
||||
EnableClipboard bool `koanf:"enableclipboard"`
|
||||
EnablePrinter bool `koanf:"enableprinter"`
|
||||
EnablePort bool `koanf:"enableport"`
|
||||
EnablePnp bool `koanf:"enablepnp"`
|
||||
EnableDrive bool `koanf:"enabledrive"`
|
||||
}
|
||||
|
||||
type SecurityConfig struct {
|
||||
PAATokenEncryptionKey string `koanf:"paatokenencryptionkey"`
|
||||
PAATokenSigningKey string `koanf:"paatokensigningkey"`
|
||||
UserTokenEncryptionKey string `koanf:"usertokenencryptionkey"`
|
||||
UserTokenSigningKey string `koanf:"usertokensigningkey"`
|
||||
QueryTokenSigningKey string `koanf:"querytokensigningkey"`
|
||||
QueryTokenIssuer string `koanf:"querytokenissuer"`
|
||||
VerifyClientIp bool `koanf:"verifyclientip"`
|
||||
EnableUserToken bool `koanf:"enableusertoken"`
|
||||
}
|
||||
|
||||
type ClientConfig struct {
|
||||
Defaults string `koanf:"defaults"`
|
||||
// kept for backwards compatibility
|
||||
UsernameTemplate string `koanf:"usernametemplate"`
|
||||
SplitUserDomain bool `koanf:"splituserdomain"`
|
||||
NoUsername bool `koanf:"nousername"`
|
||||
}
|
||||
|
||||
func ToCamel(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
n := strings.Builder{}
|
||||
n.Grow(len(s))
|
||||
var capNext bool = true
|
||||
for i, v := range []byte(s) {
|
||||
vIsCap := v >= 'A' && v <= 'Z'
|
||||
vIsLow := v >= 'a' && v <= 'z'
|
||||
if capNext {
|
||||
if vIsLow {
|
||||
v += 'A'
|
||||
v -= 'a'
|
||||
}
|
||||
} else if i == 0 {
|
||||
if vIsCap {
|
||||
v += 'a'
|
||||
v -= 'A'
|
||||
}
|
||||
}
|
||||
if vIsCap || vIsLow {
|
||||
n.WriteByte(v)
|
||||
capNext = false
|
||||
} else if vIsNum := v >= '0' && v <= '9'; vIsNum {
|
||||
n.WriteByte(v)
|
||||
capNext = true
|
||||
} else {
|
||||
capNext = v == '_' || v == ' ' || v == '-' || v == '.'
|
||||
if v == '.' {
|
||||
n.WriteByte(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
return n.String()
|
||||
}
|
||||
|
||||
var Conf Configuration
|
||||
|
||||
func Load(configFile string) Configuration {
|
||||
|
||||
var k = koanf.New(".")
|
||||
|
||||
k.Load(confmap.Provider(map[string]interface{}{
|
||||
"Server.Tls": "auto",
|
||||
"Server.Port": 443,
|
||||
"Server.SessionStore": "cookie",
|
||||
"Server.HostSelection": "roundrobin",
|
||||
"Server.Authentication": "openid",
|
||||
"Server.AuthSocket": "/tmp/rdpgw-auth.sock",
|
||||
"Server.BasicAuthTimeout": 5,
|
||||
"Client.NetworkAutoDetect": 1,
|
||||
"Client.BandwidthAutoDetect": 1,
|
||||
"Security.VerifyClientIp": true,
|
||||
"Caps.TokenAuth": true,
|
||||
}, "."), nil)
|
||||
|
||||
if _, err := os.Stat(configFile); os.IsNotExist(err) {
|
||||
log.Printf("Config file %s not found, using defaults and environment", configFile)
|
||||
} else {
|
||||
if err := k.Load(file.Provider(configFile), yaml.Parser()); err != nil {
|
||||
log.Fatalf("Error loading config from file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := k.Load(env.ProviderWithValue("RDPGW_", ".", func(s string, v string) (string, interface{}) {
|
||||
key := strings.Replace(strings.ToLower(strings.TrimPrefix(s, "RDPGW_")), "__", ".", -1)
|
||||
key = ToCamel(key)
|
||||
|
||||
v = strings.Trim(v, " ")
|
||||
|
||||
// handle lists
|
||||
if strings.Contains(v, " ") {
|
||||
return key, strings.Split(v, " ")
|
||||
}
|
||||
return key, v
|
||||
|
||||
}), nil); err != nil {
|
||||
log.Fatalf("Error loading config from environment: %v", err)
|
||||
}
|
||||
|
||||
koanfTag := koanf.UnmarshalConf{Tag: "koanf"}
|
||||
k.UnmarshalWithConf("Server", &Conf.Server, koanfTag)
|
||||
k.UnmarshalWithConf("OpenId", &Conf.OpenId, koanfTag)
|
||||
k.UnmarshalWithConf("Caps", &Conf.Caps, koanfTag)
|
||||
k.UnmarshalWithConf("Security", &Conf.Security, koanfTag)
|
||||
k.UnmarshalWithConf("Client", &Conf.Client, koanfTag)
|
||||
k.UnmarshalWithConf("Kerberos", &Conf.Kerberos, koanfTag)
|
||||
k.UnmarshalWithConf("PXVDI", &Conf.PXVDI, koanfTag)
|
||||
|
||||
if len(Conf.Security.PAATokenEncryptionKey) != 32 {
|
||||
Conf.Security.PAATokenEncryptionKey, _ = security.GenerateRandomString(32)
|
||||
log.Printf("No valid `security.paatokenencryptionkey` specified (empty or not 32 characters). Setting to random")
|
||||
}
|
||||
|
||||
if len(Conf.Security.PAATokenSigningKey) != 32 {
|
||||
Conf.Security.PAATokenSigningKey, _ = security.GenerateRandomString(32)
|
||||
log.Printf("No valid `security.paatokensigningkey` specified (empty or not 32 characters). Setting to random")
|
||||
}
|
||||
|
||||
if Conf.Security.EnableUserToken {
|
||||
if len(Conf.Security.UserTokenEncryptionKey) != 32 {
|
||||
Conf.Security.UserTokenEncryptionKey, _ = security.GenerateRandomString(32)
|
||||
log.Printf("No valid `security.usertokenencryptionkey` specified (empty or not 32 characters). Setting to random")
|
||||
}
|
||||
|
||||
if len(Conf.Security.UserTokenSigningKey) != 32 {
|
||||
Conf.Security.UserTokenSigningKey, _ = security.GenerateRandomString(32)
|
||||
log.Printf("No valid `security.usertokensigningkey` specified (empty or not 32 characters). Setting to random")
|
||||
}
|
||||
}
|
||||
|
||||
if len(Conf.Server.SessionKey) != 32 {
|
||||
Conf.Server.SessionKey, _ = security.GenerateRandomString(32)
|
||||
log.Printf("No valid `server.sessionkey` specified (empty or not 32 characters). Setting to random")
|
||||
}
|
||||
|
||||
if len(Conf.Server.SessionEncryptionKey) != 32 {
|
||||
Conf.Server.SessionEncryptionKey, _ = security.GenerateRandomString(32)
|
||||
log.Printf("No valid `server.sessionencryptionkey` specified (empty or not 32 characters). Setting to random")
|
||||
}
|
||||
|
||||
if Conf.Server.HostSelection == "signed" && len(Conf.Security.QueryTokenSigningKey) == 0 {
|
||||
log.Fatalf("host selection is set to `signed` but `querytokensigningkey` is not set")
|
||||
}
|
||||
|
||||
if Conf.Server.BasicAuthEnabled() && Conf.Server.Tls == "disable" {
|
||||
log.Fatalf("basicauth=local and tls=disable are mutually exclusive")
|
||||
}
|
||||
|
||||
if Conf.Server.NtlmEnabled() && Conf.Server.KerberosEnabled() {
|
||||
log.Fatalf("ntlm and kerberos authentication are not stackable")
|
||||
}
|
||||
|
||||
if !Conf.Caps.TokenAuth && Conf.Server.OpenIDEnabled() {
|
||||
log.Fatalf("openid is configured but tokenauth disabled")
|
||||
}
|
||||
|
||||
if Conf.Server.KerberosEnabled() && Conf.Kerberos.Keytab == "" {
|
||||
log.Fatalf("kerberos is configured but no keytab was specified")
|
||||
}
|
||||
|
||||
// prepend '//' if required for URL parsing
|
||||
if !strings.Contains(Conf.Server.GatewayAddress, "//") {
|
||||
Conf.Server.GatewayAddress = "//" + Conf.Server.GatewayAddress
|
||||
}
|
||||
|
||||
return Conf
|
||||
|
||||
}
|
||||
|
||||
func (s *ServerConfig) OpenIDEnabled() bool {
|
||||
return s.matchAuth("openid")
|
||||
}
|
||||
|
||||
func (s *ServerConfig) KerberosEnabled() bool {
|
||||
return s.matchAuth("kerberos")
|
||||
}
|
||||
|
||||
func (s *ServerConfig) BasicAuthEnabled() bool {
|
||||
return s.matchAuth("local") || s.matchAuth("basic")
|
||||
}
|
||||
|
||||
func (s *ServerConfig) NtlmEnabled() bool {
|
||||
return s.matchAuth("ntlm")
|
||||
}
|
||||
|
||||
func (s *ServerConfig) matchAuth(needle string) bool {
|
||||
for _, q := range s.Authentication {
|
||||
if q == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
57
cmd/rdpgw/identity/identity.go
Normal file
57
cmd/rdpgw/identity/identity.go
Normal file
@ -0,0 +1,57 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
CTXKey = "github.com/bolkedebruin/rdpgw/common/identity"
|
||||
|
||||
AttrRemoteAddr = "remoteAddr"
|
||||
AttrClientIp = "clientIp"
|
||||
AttrProxies = "proxyAddresses"
|
||||
AttrAccessToken = "accessToken" // todo remove for security reasons
|
||||
)
|
||||
|
||||
type Identity interface {
|
||||
UserName() string
|
||||
SetUserName(string)
|
||||
DisplayName() string
|
||||
SetDisplayName(string)
|
||||
Domain() string
|
||||
SetDomain(string)
|
||||
Authenticated() bool
|
||||
SetAuthenticated(bool)
|
||||
AuthTime() time.Time
|
||||
SetAuthTime(time2 time.Time)
|
||||
SessionId() string
|
||||
SetAttribute(string, interface{})
|
||||
GetAttribute(string) interface{}
|
||||
Attributes() map[string]interface{}
|
||||
DelAttribute(string)
|
||||
Email() string
|
||||
SetEmail(string)
|
||||
Expiry() time.Time
|
||||
SetExpiry(time.Time)
|
||||
Marshal() ([]byte, error)
|
||||
Unmarshal([]byte) error
|
||||
}
|
||||
|
||||
func AddToRequestCtx(id Identity, r *http.Request) *http.Request {
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, CTXKey, id)
|
||||
return r.WithContext(ctx)
|
||||
}
|
||||
|
||||
func FromRequestCtx(r *http.Request) Identity {
|
||||
return FromCtx(r.Context())
|
||||
}
|
||||
|
||||
func FromCtx(ctx context.Context) Identity {
|
||||
if id, ok := ctx.Value(CTXKey).(Identity); ok {
|
||||
return id
|
||||
}
|
||||
return nil
|
||||
}
|
||||
28
cmd/rdpgw/identity/identity_test.go
Normal file
28
cmd/rdpgw/identity/identity_test.go
Normal file
@ -0,0 +1,28 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"log"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMarshalling(t *testing.T) {
|
||||
u := NewUser()
|
||||
u.SetUserName("ANAME")
|
||||
u.SetAuthenticated(true)
|
||||
u.SetDomain("DOMAIN")
|
||||
|
||||
c := NewUser()
|
||||
data, err := u.Marshal()
|
||||
if err != nil {
|
||||
log.Fatalf("Cannot marshal %s", err)
|
||||
}
|
||||
|
||||
err = c.Unmarshal(data)
|
||||
if err != nil {
|
||||
t.Fatalf("Error while unmarshalling: %s", err)
|
||||
}
|
||||
|
||||
if u.UserName() != c.UserName() || u.Authenticated() != c.Authenticated() || u.Domain() != c.Domain() {
|
||||
t.Fatalf("identities not equal: %+v != %+v", u, c)
|
||||
}
|
||||
}
|
||||
170
cmd/rdpgw/identity/user.go
Normal file
170
cmd/rdpgw/identity/user.go
Normal file
@ -0,0 +1,170 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/gob"
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
authenticated bool
|
||||
domain string
|
||||
userName string
|
||||
displayName string
|
||||
email string
|
||||
authTime time.Time
|
||||
sessionId string
|
||||
expiry time.Time
|
||||
attributes map[string]interface{}
|
||||
groupMembership map[string]bool
|
||||
}
|
||||
|
||||
type user struct {
|
||||
Authenticated bool
|
||||
UserName string
|
||||
Domain string
|
||||
DisplayName string
|
||||
Email string
|
||||
AuthTime time.Time
|
||||
SessionId string
|
||||
Expiry time.Time
|
||||
Attributes map[string]interface{}
|
||||
GroupMembership map[string]bool
|
||||
}
|
||||
|
||||
func NewUser() *User {
|
||||
uuid := uuid.New().String()
|
||||
return &User{
|
||||
attributes: make(map[string]interface{}),
|
||||
groupMembership: make(map[string]bool),
|
||||
sessionId: uuid,
|
||||
}
|
||||
}
|
||||
|
||||
func (u *User) UserName() string {
|
||||
return u.userName
|
||||
}
|
||||
|
||||
func (u *User) SetUserName(s string) {
|
||||
u.userName = s
|
||||
}
|
||||
|
||||
func (u *User) DisplayName() string {
|
||||
if u.displayName == "" {
|
||||
return u.userName
|
||||
}
|
||||
return u.displayName
|
||||
}
|
||||
|
||||
func (u *User) SetDisplayName(s string) {
|
||||
u.displayName = s
|
||||
}
|
||||
|
||||
func (u *User) Domain() string {
|
||||
return u.domain
|
||||
}
|
||||
|
||||
func (u *User) SetDomain(s string) {
|
||||
u.domain = s
|
||||
}
|
||||
|
||||
func (u *User) Authenticated() bool {
|
||||
return u.authenticated
|
||||
}
|
||||
|
||||
func (u *User) SetAuthenticated(b bool) {
|
||||
u.authenticated = b
|
||||
}
|
||||
|
||||
func (u *User) AuthTime() time.Time {
|
||||
return u.authTime
|
||||
}
|
||||
|
||||
func (u *User) SetAuthTime(t time.Time) {
|
||||
u.authTime = t
|
||||
}
|
||||
|
||||
func (u *User) SessionId() string {
|
||||
return u.sessionId
|
||||
}
|
||||
|
||||
func (u *User) SetAttribute(s string, i interface{}) {
|
||||
u.attributes[s] = i
|
||||
}
|
||||
|
||||
func (u *User) GetAttribute(s string) interface{} {
|
||||
if found, ok := u.attributes[s]; ok {
|
||||
return found
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *User) Attributes() map[string]interface{} {
|
||||
return u.attributes
|
||||
}
|
||||
|
||||
func (u *User) DelAttribute(s string) {
|
||||
delete(u.attributes, s)
|
||||
}
|
||||
|
||||
func (u *User) Email() string {
|
||||
return u.email
|
||||
}
|
||||
|
||||
func (u *User) SetEmail(s string) {
|
||||
u.email = s
|
||||
}
|
||||
|
||||
func (u *User) Expiry() time.Time {
|
||||
return u.expiry
|
||||
}
|
||||
|
||||
func (u *User) SetExpiry(t time.Time) {
|
||||
u.expiry = t
|
||||
}
|
||||
|
||||
func (u *User) Marshal() ([]byte, error) {
|
||||
buf := new(bytes.Buffer)
|
||||
enc := gob.NewEncoder(buf)
|
||||
uu := user{
|
||||
Authenticated: u.authenticated,
|
||||
UserName: u.userName,
|
||||
Domain: u.domain,
|
||||
DisplayName: u.displayName,
|
||||
Email: u.email,
|
||||
AuthTime: u.authTime,
|
||||
SessionId: u.sessionId,
|
||||
Expiry: u.expiry,
|
||||
Attributes: u.attributes,
|
||||
GroupMembership: u.groupMembership,
|
||||
}
|
||||
err := enc.Encode(uu)
|
||||
|
||||
if err != nil {
|
||||
return []byte{}, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (u *User) Unmarshal(b []byte) error {
|
||||
buf := bytes.NewBuffer(b)
|
||||
dec := gob.NewDecoder(buf)
|
||||
var uu user
|
||||
err := dec.Decode(&uu)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.sessionId = uu.SessionId
|
||||
u.userName = uu.UserName
|
||||
u.domain = uu.Domain
|
||||
u.displayName = uu.DisplayName
|
||||
u.email = uu.Email
|
||||
u.authenticated = uu.Authenticated
|
||||
u.authTime = uu.AuthTime
|
||||
u.expiry = uu.Expiry
|
||||
u.attributes = uu.Attributes
|
||||
u.groupMembership = uu.GroupMembership
|
||||
|
||||
return nil
|
||||
}
|
||||
210
cmd/rdpgw/kdcproxy/proxy.go
Normal file
210
cmd/rdpgw/kdcproxy/proxy.go
Normal file
@ -0,0 +1,210 @@
|
||||
package kdcproxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
krbconfig "github.com/bolkedebruin/gokrb5/v8/config"
|
||||
"github.com/jcmturner/gofork/encoding/asn1"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxLength = 128 * 1024
|
||||
systemConfigPath = "/etc/krb5.conf"
|
||||
timeout = 5 * time.Second
|
||||
)
|
||||
|
||||
type KdcProxyMsg struct {
|
||||
Message []byte `asn1:"tag:0,explicit"`
|
||||
Realm string `asn1:"tag:1,optional"`
|
||||
Flags int `asn1:"tag:2,optional"`
|
||||
}
|
||||
|
||||
type Kdc struct {
|
||||
Realm string
|
||||
Host string
|
||||
Proto string
|
||||
Conn net.Conn
|
||||
}
|
||||
|
||||
type KerberosProxy struct {
|
||||
krb5Config *krbconfig.Config
|
||||
}
|
||||
|
||||
func InitKdcProxy(krb5Conf string) KerberosProxy {
|
||||
path := systemConfigPath
|
||||
if krb5Conf != "" {
|
||||
path = krb5Conf
|
||||
}
|
||||
cfg, err := krbconfig.Load(path)
|
||||
if err != nil {
|
||||
log.Fatalf("Cannot load krb5 config %s due to %s", path, err)
|
||||
}
|
||||
|
||||
return KerberosProxy{
|
||||
krb5Config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (k KerberosProxy) Handler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
length := r.ContentLength
|
||||
if length == -1 {
|
||||
http.Error(w, "Content length required", http.StatusLengthRequired)
|
||||
return
|
||||
}
|
||||
|
||||
if length > maxLength {
|
||||
http.Error(w, "Request entity too large", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
|
||||
data := make([]byte, length)
|
||||
_, err := io.ReadFull(r.Body, data)
|
||||
if err != nil {
|
||||
log.Printf("Error reading from stream: %s", err)
|
||||
http.Error(w, "Error reading from stream", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := decode(data)
|
||||
if err != nil {
|
||||
log.Printf("Cannot unmarshal: %s", err)
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
krb5resp, err := k.forward(msg.Realm, msg.Message)
|
||||
if err != nil {
|
||||
log.Printf("cannot forward to kdc due to %s", err)
|
||||
http.Error(w, "Service unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
reply, err := encode(krb5resp)
|
||||
if err != nil {
|
||||
log.Printf("unable to encode krb5 message due to %s", err)
|
||||
http.Error(w, "encoding error", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/kerberos")
|
||||
w.Write(reply)
|
||||
}
|
||||
|
||||
func (k *KerberosProxy) forward(realm string, data []byte) (resp []byte, err error) {
|
||||
if realm == "" {
|
||||
realm = k.krb5Config.LibDefaults.DefaultRealm
|
||||
}
|
||||
|
||||
// load udp first as is the default for kerberos
|
||||
udpCnt, udpKdcs, err := k.krb5Config.GetKDCs(realm, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot get udp kdc for realm %s due to %s", realm, err)
|
||||
}
|
||||
|
||||
// load tcp
|
||||
tcpCnt, tcpKdcs, err := k.krb5Config.GetKDCs(realm, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot get tcp kdc for realm %s due to %s", realm, err)
|
||||
}
|
||||
|
||||
if tcpCnt+udpCnt == 0 {
|
||||
return nil, fmt.Errorf("cannot get any kdcs (tcp or udp) for realm %s", realm)
|
||||
}
|
||||
|
||||
// merge the kdcs
|
||||
kdcs := make([]Kdc, tcpCnt+udpCnt)
|
||||
for i := range udpKdcs {
|
||||
kdcs[i] = Kdc{Realm: realm, Host: udpKdcs[i], Proto: "udp"}
|
||||
}
|
||||
for i := range tcpKdcs {
|
||||
kdcs[i+udpCnt] = Kdc{Realm: realm, Host: tcpKdcs[i], Proto: "tcp"}
|
||||
}
|
||||
|
||||
replies := make(chan []byte, len(kdcs))
|
||||
for i := range kdcs {
|
||||
conn, err := net.Dial(kdcs[i].Proto, kdcs[i].Host)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("error connecting to %s due to %s, trying next if available", kdcs[i], err)
|
||||
continue
|
||||
}
|
||||
conn.SetDeadline(time.Now().Add(timeout))
|
||||
|
||||
// if we proxy over UDP remove the length prefix
|
||||
if kdcs[i].Proto == "tcp" {
|
||||
_, err = conn.Write(data)
|
||||
} else {
|
||||
_, err = conn.Write(data[4:])
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("cannot write packet data to %s due to %s, trying next if available", kdcs[i], err)
|
||||
conn.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
kdcs[i].Conn = conn
|
||||
go awaitReply(conn, kdcs[i].Proto == "udp", replies)
|
||||
}
|
||||
|
||||
reply := <-replies
|
||||
|
||||
// close all the connections and return the first reply
|
||||
for kdc := range kdcs {
|
||||
if kdcs[kdc].Conn != nil {
|
||||
kdcs[kdc].Conn.Close()
|
||||
}
|
||||
<-replies
|
||||
}
|
||||
|
||||
if reply != nil {
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no replies received from kdcs for realm %s", realm)
|
||||
}
|
||||
|
||||
func decode(data []byte) (msg *KdcProxyMsg, err error) {
|
||||
var m KdcProxyMsg
|
||||
rest, err := asn1.Unmarshal(data, &m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(rest) > 0 {
|
||||
return nil, fmt.Errorf("trailing data in request")
|
||||
}
|
||||
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func encode(krb5data []byte) (r []byte, err error) {
|
||||
m := KdcProxyMsg{Message: krb5data}
|
||||
enc, err := asn1.Marshal(m)
|
||||
if err != nil {
|
||||
log.Printf("cannot marshal due to %s", err)
|
||||
return nil, err
|
||||
}
|
||||
return enc, nil
|
||||
}
|
||||
|
||||
func awaitReply(conn net.Conn, isUdp bool, reply chan<- []byte) {
|
||||
resp, err := io.ReadAll(conn)
|
||||
if err != nil {
|
||||
log.Printf("error reading from kdc due to %s", err)
|
||||
reply <- nil
|
||||
return
|
||||
}
|
||||
if isUdp {
|
||||
// udp will be missing the length prefix so add it
|
||||
resp = append([]byte{byte(len(resp))}, resp...)
|
||||
}
|
||||
reply <- resp
|
||||
}
|
||||
312
cmd/rdpgw/main.go
Normal file
312
cmd/rdpgw/main.go
Normal file
@ -0,0 +1,312 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/bolkedebruin/gokrb5/v8/keytab"
|
||||
"github.com/bolkedebruin/gokrb5/v8/service"
|
||||
"github.com/bolkedebruin/gokrb5/v8/spnego"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/config"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/kdcproxy"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/protocol"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/security"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/web"
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/thought-machine/go-flags"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
"golang.org/x/oauth2"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
gatewayEndPoint = "/remoteDesktopGateway/"
|
||||
kdcProxyEndPoint = "/KdcProxy"
|
||||
)
|
||||
|
||||
var opts struct {
|
||||
ConfigFile string `short:"c" long:"conf" default:"rdpgw.yaml" description:"config file (yaml)"`
|
||||
}
|
||||
|
||||
var conf config.Configuration
|
||||
|
||||
func initOIDC(callbackUrl *url.URL) *web.OIDC {
|
||||
// set oidc config
|
||||
provider, err := oidc.NewProvider(context.Background(), conf.OpenId.ProviderUrl)
|
||||
if err != nil {
|
||||
log.Fatalf("Cannot get oidc provider: %s", err)
|
||||
}
|
||||
oidcConfig := &oidc.Config{
|
||||
ClientID: conf.OpenId.ClientId,
|
||||
}
|
||||
verifier := provider.Verifier(oidcConfig)
|
||||
|
||||
oauthConfig := oauth2.Config{
|
||||
ClientID: conf.OpenId.ClientId,
|
||||
ClientSecret: conf.OpenId.ClientSecret,
|
||||
RedirectURL: callbackUrl.String(),
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||
}
|
||||
security.OIDCProvider = provider
|
||||
security.Oauth2Config = oauthConfig
|
||||
|
||||
o := web.OIDCConfig{
|
||||
OAuth2Config: &oauthConfig,
|
||||
OIDCTokenVerifier: verifier,
|
||||
}
|
||||
|
||||
return o.New()
|
||||
}
|
||||
|
||||
// 定时记录连接用户信息
|
||||
func startConnectionLogger(interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
connections := protocol.GetActiveConnections()
|
||||
if len(connections) > 0 {
|
||||
connData, err := json.Marshal(connections)
|
||||
if err != nil {
|
||||
log.Printf("连接信息序列化失败: %v", err)
|
||||
continue
|
||||
}
|
||||
log.Printf("当前活跃连接数: %d, 连接详情: %s", len(connections), string(connData))
|
||||
} else {
|
||||
log.Printf("当前无活跃连接")
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("启动连接信息记录器,间隔时间: %v", interval)
|
||||
}
|
||||
|
||||
func main() {
|
||||
// load config
|
||||
_, err := flags.Parse(&opts)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
conf = config.Load(opts.ConfigFile)
|
||||
|
||||
// set callback url and external advertised gateway address
|
||||
url, err := url.Parse(conf.Server.GatewayAddress)
|
||||
if err != nil {
|
||||
log.Printf("Cannot parse server gateway address %s due to %s", url, err)
|
||||
}
|
||||
if url.Scheme == "" {
|
||||
url.Scheme = "https"
|
||||
}
|
||||
url.Path = "callback"
|
||||
|
||||
// set security options
|
||||
security.VerifyClientIP = conf.Security.VerifyClientIp
|
||||
security.SigningKey = []byte(conf.Security.PAATokenSigningKey)
|
||||
security.EncryptionKey = []byte(conf.Security.PAATokenEncryptionKey)
|
||||
security.UserEncryptionKey = []byte(conf.Security.UserTokenEncryptionKey)
|
||||
security.UserSigningKey = []byte(conf.Security.UserTokenSigningKey)
|
||||
security.QuerySigningKey = []byte(conf.Security.QueryTokenSigningKey)
|
||||
security.HostSelection = conf.Server.HostSelection
|
||||
security.Hosts = conf.Server.Hosts
|
||||
|
||||
// init session store
|
||||
web.InitStore([]byte(conf.Server.SessionKey),
|
||||
[]byte(conf.Server.SessionEncryptionKey),
|
||||
conf.Server.SessionStore,
|
||||
conf.Server.MaxSessionLength,
|
||||
)
|
||||
|
||||
// configure web backend
|
||||
w := &web.Config{
|
||||
QueryInfo: security.QueryInfo,
|
||||
QueryTokenIssuer: conf.Security.QueryTokenIssuer,
|
||||
EnableUserToken: conf.Security.EnableUserToken,
|
||||
Hosts: conf.Server.Hosts,
|
||||
HostSelection: conf.Server.HostSelection,
|
||||
RdpOpts: web.RdpOpts{
|
||||
UsernameTemplate: conf.Client.UsernameTemplate,
|
||||
SplitUserDomain: conf.Client.SplitUserDomain,
|
||||
NoUsername: conf.Client.NoUsername,
|
||||
},
|
||||
GatewayAddress: url,
|
||||
TemplateFile: conf.Client.Defaults,
|
||||
}
|
||||
|
||||
if conf.Caps.TokenAuth {
|
||||
w.PAATokenGenerator = security.GeneratePAAToken
|
||||
}
|
||||
if conf.Security.EnableUserToken {
|
||||
w.UserTokenGenerator = security.GenerateUserToken
|
||||
}
|
||||
h := w.NewHandler()
|
||||
|
||||
log.Printf("Starting remote desktop gateway server")
|
||||
|
||||
// 启动连接信息记录器
|
||||
startConnectionLogger(10 * time.Second)
|
||||
|
||||
cfg := &tls.Config{}
|
||||
|
||||
// configure tls security
|
||||
if conf.Server.Tls == config.TlsDisable {
|
||||
log.Printf("TLS disabled - rdp gw connections require tls, make sure to have a terminator")
|
||||
} else {
|
||||
// auto config
|
||||
tlsConfigured := false
|
||||
|
||||
tlsDebug := os.Getenv("SSLKEYLOGFILE")
|
||||
if tlsDebug != "" {
|
||||
w, err := os.OpenFile(tlsDebug, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
log.Fatalf("Cannot open key log file %s for writing %s", tlsDebug, err)
|
||||
}
|
||||
log.Printf("Key log file set to: %s", tlsDebug)
|
||||
cfg.KeyLogWriter = w
|
||||
}
|
||||
|
||||
if conf.Server.KeyFile != "" && conf.Server.CertFile != "" {
|
||||
cert, err := tls.LoadX509KeyPair(conf.Server.CertFile, conf.Server.KeyFile)
|
||||
if err != nil {
|
||||
log.Printf("Cannot load certfile or keyfile (%s) falling back to acme", err)
|
||||
}
|
||||
cfg.Certificates = append(cfg.Certificates, cert)
|
||||
tlsConfigured = true
|
||||
}
|
||||
|
||||
if !tlsConfigured {
|
||||
log.Printf("Using acme / letsencrypt for tls configuration. Enabling http (port 80) for verification")
|
||||
// setup a simple handler which sends a HTHS header for six months (!)
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Strict-Transport-Security", "max-age=15768000 ; includeSubDomains")
|
||||
fmt.Fprintf(w, "Hello from RDPGW")
|
||||
})
|
||||
|
||||
certMgr := autocert.Manager{
|
||||
Prompt: autocert.AcceptTOS,
|
||||
HostPolicy: autocert.HostWhitelist(url.Host),
|
||||
Cache: autocert.DirCache("/tmp/rdpgw"),
|
||||
}
|
||||
cfg.GetCertificate = certMgr.GetCertificate
|
||||
|
||||
go func() {
|
||||
http.ListenAndServe(":80", certMgr.HTTPHandler(nil))
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// gateway confg
|
||||
gw := protocol.Gateway{
|
||||
RedirectFlags: protocol.RedirectFlags{
|
||||
Clipboard: conf.Caps.EnableClipboard,
|
||||
Drive: conf.Caps.EnableDrive,
|
||||
Printer: conf.Caps.EnablePrinter,
|
||||
Port: conf.Caps.EnablePort,
|
||||
Pnp: conf.Caps.EnablePnp,
|
||||
DisableAll: conf.Caps.DisableRedirect,
|
||||
EnableAll: conf.Caps.RedirectAll,
|
||||
},
|
||||
IdleTimeout: conf.Caps.IdleTimeout,
|
||||
SmartCardAuth: conf.Caps.SmartCardAuth,
|
||||
TokenAuth: conf.Caps.TokenAuth,
|
||||
ReceiveBuf: conf.Server.ReceiveBuf,
|
||||
SendBuf: conf.Server.SendBuf,
|
||||
}
|
||||
|
||||
if conf.Caps.TokenAuth {
|
||||
gw.CheckPAACookie = security.CheckPAACookie
|
||||
gw.CheckHost = security.CheckSession(security.CheckHost)
|
||||
} else {
|
||||
gw.CheckHost = security.CheckHost
|
||||
}
|
||||
|
||||
r := mux.NewRouter()
|
||||
|
||||
// ensure identity is set in context and get some extra info
|
||||
r.Use(web.EnrichContext)
|
||||
|
||||
// prometheus metrics
|
||||
r.Handle("/metrics", promhttp.Handler())
|
||||
|
||||
// for sso callbacks
|
||||
r.HandleFunc("/tokeninfo", web.TokenInfo)
|
||||
|
||||
// gateway endpoint
|
||||
rdp := r.PathPrefix(gatewayEndPoint).Subrouter()
|
||||
|
||||
// openid
|
||||
if conf.Server.OpenIDEnabled() {
|
||||
log.Printf("enabling openid extended authentication")
|
||||
o := initOIDC(url)
|
||||
r.Handle("/connect", o.Authenticated(http.HandlerFunc(h.HandleDownload)))
|
||||
r.HandleFunc("/callback", o.HandleCallback)
|
||||
|
||||
// only enable un-auth endpoint for openid only config
|
||||
if !conf.Server.KerberosEnabled() && !conf.Server.BasicAuthEnabled() && !conf.Server.NtlmEnabled() {
|
||||
rdp.Name("gw").HandlerFunc(gw.HandleGatewayProtocol)
|
||||
}
|
||||
}
|
||||
|
||||
// for stacking of authentication
|
||||
auth := web.NewAuthMux()
|
||||
rdp.MatcherFunc(web.NoAuthz).HandlerFunc(auth.SetAuthenticate)
|
||||
|
||||
// ntlm
|
||||
if conf.Server.NtlmEnabled() {
|
||||
log.Printf("enabling NTLM authentication")
|
||||
ntlm := web.NTLMAuthHandler{SocketAddress: conf.Server.AuthSocket, Timeout: conf.Server.BasicAuthTimeout}
|
||||
rdp.NewRoute().HeadersRegexp("Authorization", "NTLM").HandlerFunc(ntlm.NTLMAuth(gw.HandleGatewayProtocol))
|
||||
rdp.NewRoute().HeadersRegexp("Authorization", "Negotiate").HandlerFunc(ntlm.NTLMAuth(gw.HandleGatewayProtocol))
|
||||
auth.Register(`NTLM`)
|
||||
auth.Register(`Negotiate`)
|
||||
}
|
||||
|
||||
// basic auth
|
||||
if conf.Server.BasicAuthEnabled() {
|
||||
log.Printf("enabling basic authentication")
|
||||
q := web.BasicAuthHandler{SocketAddress: conf.Server.AuthSocket, Timeout: conf.Server.BasicAuthTimeout}
|
||||
rdp.NewRoute().HeadersRegexp("Authorization", "Basic").HandlerFunc(q.BasicAuth(gw.HandleGatewayProtocol))
|
||||
auth.Register(`Basic realm="restricted", charset="UTF-8"`)
|
||||
}
|
||||
|
||||
// spnego / kerberos
|
||||
if conf.Server.KerberosEnabled() {
|
||||
log.Printf("enabling kerberos authentication")
|
||||
keytab, err := keytab.Load(conf.Kerberos.Keytab)
|
||||
if err != nil {
|
||||
log.Fatalf("Cannot load keytab: %s", err)
|
||||
}
|
||||
rdp.NewRoute().HeadersRegexp("Authorization", "Negotiate").Handler(
|
||||
spnego.SPNEGOKRB5Authenticate(web.TransposeSPNEGOContext(http.HandlerFunc(gw.HandleGatewayProtocol)),
|
||||
keytab,
|
||||
service.Logger(log.Default())))
|
||||
|
||||
// kdcproxy
|
||||
k := kdcproxy.InitKdcProxy(conf.Kerberos.Krb5Conf)
|
||||
r.HandleFunc(kdcProxyEndPoint, k.Handler).Methods("POST")
|
||||
auth.Register("Negotiate")
|
||||
}
|
||||
|
||||
// setup server
|
||||
server := http.Server{
|
||||
Addr: ":" + strconv.Itoa(conf.Server.Port),
|
||||
Handler: r,
|
||||
TLSConfig: cfg,
|
||||
TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)), // disable http2
|
||||
}
|
||||
|
||||
if conf.Server.Tls == config.TlsDisable {
|
||||
err = server.ListenAndServe()
|
||||
} else {
|
||||
err = server.ListenAndServeTLS("", "")
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatal("ListenAndServe: ", err)
|
||||
}
|
||||
}
|
||||
@ -19,7 +19,7 @@ type ClientConfig struct {
|
||||
SmartCardAuth bool
|
||||
PAAToken string
|
||||
NTLMAuth bool
|
||||
Session *SessionInfo
|
||||
Session *Tunnel
|
||||
LocalConn net.Conn
|
||||
Server string
|
||||
Port int
|
||||
@ -27,10 +27,10 @@ type ClientConfig struct {
|
||||
}
|
||||
|
||||
func (c *ClientConfig) ConnectAndForward() error {
|
||||
c.Session.TransportOut.WritePacket(c.handshakeRequest())
|
||||
c.Session.transportOut.WritePacket(c.handshakeRequest())
|
||||
|
||||
for {
|
||||
pt, sz, pkt, err := readMessage(c.Session.TransportIn)
|
||||
pt, sz, pkt, err := readMessage(c.Session.transportIn)
|
||||
if err != nil {
|
||||
log.Printf("Cannot read message from stream %s", err)
|
||||
return err
|
||||
@ -44,7 +44,7 @@ func (c *ClientConfig) ConnectAndForward() error {
|
||||
return err
|
||||
}
|
||||
log.Printf("Handshake response received. Caps: %d", caps)
|
||||
c.Session.TransportOut.WritePacket(c.tunnelRequest())
|
||||
c.Session.transportOut.WritePacket(c.tunnelRequest())
|
||||
case PKT_TYPE_TUNNEL_RESPONSE:
|
||||
tid, caps, err := c.tunnelResponse(pkt)
|
||||
if err != nil {
|
||||
@ -52,7 +52,7 @@ func (c *ClientConfig) ConnectAndForward() error {
|
||||
return err
|
||||
}
|
||||
log.Printf("Tunnel creation succesful. Tunnel id: %d and caps %d", tid, caps)
|
||||
c.Session.TransportOut.WritePacket(c.tunnelAuthRequest())
|
||||
c.Session.transportOut.WritePacket(c.tunnelAuthRequest())
|
||||
case PKT_TYPE_TUNNEL_AUTH_RESPONSE:
|
||||
flags, timeout, err := c.tunnelAuthResponse(pkt)
|
||||
if err != nil {
|
||||
@ -60,7 +60,7 @@ func (c *ClientConfig) ConnectAndForward() error {
|
||||
return err
|
||||
}
|
||||
log.Printf("Tunnel auth succesful. Flags: %d and timeout %d", flags, timeout)
|
||||
c.Session.TransportOut.WritePacket(c.channelRequest())
|
||||
c.Session.transportOut.WritePacket(c.channelRequest())
|
||||
case PKT_TYPE_CHANNEL_RESPONSE:
|
||||
cid, err := c.channelResponse(pkt)
|
||||
if err != nil {
|
||||
@ -71,7 +71,7 @@ func (c *ClientConfig) ConnectAndForward() error {
|
||||
log.Printf("Channel id (%d) is smaller than 1. This doesnt work for Windows clients", cid)
|
||||
}
|
||||
log.Printf("Channel creation succesful. Channel id: %d", cid)
|
||||
go forward(c.LocalConn, c.Session.TransportOut)
|
||||
//go forward(c.LocalConn, c.Session.transportOut)
|
||||
case PKT_TYPE_DATA:
|
||||
receive(pkt, c.LocalConn)
|
||||
default:
|
||||
@ -4,10 +4,12 @@ import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"github.com/bolkedebruin/rdpgw/transport"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/transport"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
type RedirectFlags struct {
|
||||
@ -20,21 +22,6 @@ type RedirectFlags struct {
|
||||
EnableAll bool
|
||||
}
|
||||
|
||||
type SessionInfo struct {
|
||||
// The connection-id (RDG-ConnID) as reported by the client
|
||||
ConnId string
|
||||
// The underlying incoming transport being either websocket or legacy http
|
||||
// in case of websocket TransportOut will equal TransportIn
|
||||
TransportIn transport.Transport
|
||||
// The underlying outgoing transport being either websocket or legacy http
|
||||
// in case of websocket TransportOut will equal TransportOut
|
||||
TransportOut transport.Transport
|
||||
// The remote desktop server (rdp, vnc etc) the clients intends to connect to
|
||||
RemoteServer string
|
||||
// The obtained client ip address
|
||||
ClientIp string
|
||||
}
|
||||
|
||||
// readMessage parses and defragments a packet from a Transport. It returns
|
||||
// at most the bytes that have been reported by the packet
|
||||
func readMessage(in transport.Transport) (pt int, n int, msg []byte, err error) {
|
||||
@ -105,7 +92,7 @@ func readHeader(data []byte) (packetType uint16, size uint32, packet []byte, err
|
||||
}
|
||||
|
||||
// forwards data from a Connection to Transport and wraps it in the rdpgw protocol
|
||||
func forward(in net.Conn, out transport.Transport) {
|
||||
func forward(in net.Conn, tunnel *Tunnel) {
|
||||
defer in.Close()
|
||||
|
||||
b1 := new(bytes.Buffer)
|
||||
@ -119,7 +106,7 @@ func forward(in net.Conn, out transport.Transport) {
|
||||
}
|
||||
binary.Write(b1, binary.LittleEndian, uint16(n))
|
||||
b1.Write(buf[:n])
|
||||
out.WritePacket(createPacket(PKT_TYPE_DATA, b1.Bytes()))
|
||||
tunnel.Write(createPacket(PKT_TYPE_DATA, b1.Bytes()))
|
||||
b1.Reset()
|
||||
}
|
||||
}
|
||||
@ -136,3 +123,11 @@ func receive(data []byte, out net.Conn) {
|
||||
out.Write(pkt)
|
||||
}
|
||||
|
||||
// wrapSyscallError takes an error and a syscall name. If the error is
|
||||
// a syscall.Errno, it wraps it in a os.SyscallError using the syscall name.
|
||||
func wrapSyscallError(name string, err error) error {
|
||||
if _, ok := err.(syscall.Errno); ok {
|
||||
err = os.NewSyscallError(name, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
155
cmd/rdpgw/protocol/errors.go
Normal file
155
cmd/rdpgw/protocol/errors.go
Normal file
@ -0,0 +1,155 @@
|
||||
package protocol
|
||||
|
||||
/*
|
||||
const (
|
||||
ERROR_NO = 0x0000000
|
||||
ERROR_CLIENT_DISCONNECT = 0x0000001
|
||||
ERROR_CLIENT_LOGOFF = 0x0000002
|
||||
ERROR_NETWORK_DISCONNECT = 0x0000003
|
||||
ERROR_NOT_FOUND = 0x0000104
|
||||
ERROR_NO_MEM = 0x0000106
|
||||
ERROR_CONNECT_TIMEOUT = 0x0000108
|
||||
ERROR_SMARTCARD_SERVICE = 0x000010A
|
||||
ERROR_UNAVAILABLE = 0x0000204
|
||||
ERROR_SMARTCARD_READER = 0x000020A
|
||||
ERROR_NETWORK = 0x0000304
|
||||
ERROR_SMARTCART_NOCARD = 0x000030A
|
||||
ERROR_SECURITY = 0x0000406
|
||||
ERROR_INVALID_NAME = 0x0000408
|
||||
ERROR_SMARTCARD_SUBSYSTEM = 0x000040A
|
||||
ERROR_GENERIC = 0x0000704
|
||||
ERROR_CONSOLE_EXIST = 0x0000708
|
||||
ERROR_LICENSING_PROTOCOL = 0x0000808
|
||||
ERROR_NETWORK_GENERIC = 0x0000904
|
||||
ERROR_SECURITY_UNEXPECTED_CERTIFICATE = 0x0000907
|
||||
ERROR_LICENSING_TIMEOUT = 0x0000908
|
||||
ERROR_SECURITY_USER = 0x0000A07
|
||||
ERROR_GENERIC_UNAVAIL = 0x0000B04
|
||||
ERROR_ENCRYPTION = 0x0000B06
|
||||
ERROR_SECURITY_USER_DISABLED = 0x0000B07
|
||||
ERROR_SECURITY_NLA_REQUIRED = 0x0000B09
|
||||
ERROR_SECURITY_USER_RESTRICTION = 0x0000C07
|
||||
ERROR_DECOMPRESSION = 0x0000C08
|
||||
ERROR_SECURITY_USER_LOCKED_OUT = 0x0000D07
|
||||
ERROR_SECURITY_USER_DIALOG_REQUIRED = 0x0000D09
|
||||
ERROR_SECURITY_FIPS_REQUIRED = 0x0000E06
|
||||
ERROR_SECURITY_USER_EXPIRED = 0x0000E07
|
||||
ERROR_GENERIC_FAILED = 0x0000E08
|
||||
ERROR_SERVER_RA_UNAVAILABLE = 0x0000E09
|
||||
ERROR_SECURITY_USER_PASSWORD_EXPIRED = 0x0000F07
|
||||
ERROR_SECURITY_USER_CREDENTIALS_NOT_SENT = 0x0000F08
|
||||
ERROR_SECURITY_USER_TIME_RESTRICTION = 0x0001007
|
||||
ERROR_LOW_VIDEO = 0x0001008
|
||||
ERROR_SECURITY_USER_COMPUTER_RANGE = 0x0001107
|
||||
ERROR_SECURITY_USER_CHANGE_PASSWORD = 0x0001207
|
||||
ERROR_SECURITY_USER_LOGON_TYPE = 0x0001307
|
||||
ERROR_KRB_SUB_REQUIRED = 0x0001407
|
||||
ERROR_SECURITY_SERVER_INVALID_CERTIFICATE = 0x0001B07
|
||||
ERROR_SECURITY_SERVER_TIMESKEW = 0x0001D07
|
||||
ERROR_SECURITY_SMARTCARD_LOCKEDOUT = 0x0002207
|
||||
ERROR_RELAUNCH_APP = 0x0002507
|
||||
ERROR_UPGRADE_CLIENT = 0x0002604
|
||||
ERROR_RELAUNCH_REMOTE = 0x2000001
|
||||
ERROR_REMOTEAPP_UNSUPPORTED = 0x2000002
|
||||
ERROR_SECURITY_USER_PASSWORD_INVALID = 0x3000001
|
||||
ERROR_SECURITY_CERTIFICATE_REVOKE_LIST_UNAVAIL = 0x3000002
|
||||
ERROR_SECURITY_CERTIFICATE_INVALID = 0x3000003
|
||||
ERROR_SECURITY_CERTIFICATE_REVOKED = 0x3000004
|
||||
ERROR_SECURITY_GATEWAY_IDENTITY = 0x3000005
|
||||
ERROR_SECURITY_GATEWAY_SUBJECT = 0x3000006
|
||||
ERROR_SECURITY_GATEWAY_EXPIRED = 0x3000007
|
||||
ERROR_SECURITY_REMOTE_ERROR = 0x3000008
|
||||
ERROR_GATEWAY_NETWORK_SEND = 0x3000009
|
||||
ERROR_GATEWAY_NETWORK_RECEIVE = 0x300000A
|
||||
ERROR_SECURITY_ALTERNATE = 0x300000B
|
||||
ERROR_GATEWAY_INVALID_ADDRESS = 0x300000C
|
||||
ERROR_GATEWAY_TEMP_UNAVAIL = 0x300000D
|
||||
ERROR_REMOTE_CLIENT_MISSING = 0x300000E
|
||||
ERROR_GATEWAY_LOW_RESOURCES = 0x300000F
|
||||
ERROR_GATEWAY_CLIENT_DLL = 0x3000010
|
||||
ERROR_SMARTCART_NOSERVICE = 0x3000011
|
||||
ERROR_SECURITY_SMARTCARD_REMOVED = 0x3000012
|
||||
ERROR_SECURITY_SMARTCARD_REQUIRED = 0x3000013
|
||||
ERROR_SECURITY_SMARTCARD_REMOVED2 = 0x3000014
|
||||
ERROR_SECURITY_USER_PASSWORD_INVALID2 = 0x3000015
|
||||
ERROR_SECURITY_TRANSPORT = 0x3000017
|
||||
ERROR_GATEWAY_TERMINATE = 0x3000018
|
||||
ERROR_GATEWAY_ADMIN_TERMINATE = 0x3000019
|
||||
ERROR_SECURITY_USER_CREDENTIALS = 0x300001A
|
||||
ERROR_SECURITY_GATEWAY_NOT_PERMITTED = 0x300001B
|
||||
ERROR_SECURITY_GATEWAY_UNAUTHORIZED = 0x300001C
|
||||
ERROR_SECURITY_GATEWAY_RESTRICTED = 0x300001F
|
||||
ERROR_SECURITY_PROXY_AUTH = 0x3000020
|
||||
ERROR_SECURITY_USER_PASSWORD_MUST_CHANGE = 0x3000021
|
||||
ERROR_GATEWAY_MAX_REACHED = 0x3000022
|
||||
ERROR_GATEWAY_UNSUPPORTED_REQUEST = 0x3000023
|
||||
ERROR_GATEWAY_UNSUPPORTED_CAP = 0x3000024
|
||||
ERROR_GATEWAY_INCOMPAT = 0x3000025
|
||||
ERROR_SECURITY_SMARTCARD_INVALID_CREDENTIALS = 0x3000026
|
||||
ERROR_SECURITY_NLA_INVALID = 0x3000027
|
||||
ERROR_GATEWAY_NO_CERTIFICATE = 0x3000028
|
||||
ERROR_GATEWAY_NOT_ALLOWED = 0x3000029
|
||||
ERROR_GATEWAY_INVALID_CERTIFICATE = 0x300002A
|
||||
ERROR_SECURITY_GATEWAY_USER_PASSWORD_REQUIRED = 0x300002B
|
||||
ERROR_SECURITY_GATEWAY_SMARTCARD_REQUIRED = 0x300002C
|
||||
ERROR_SECURITY_SMARTCARD_UNAVAIL = 0x300002D
|
||||
ERROR_SECURITY_FIREWALL_NOAUTH = 0x300002F
|
||||
ERROR_SECURITY_FIREWALL_AUTH = 0x3000030
|
||||
ERROR_NO_INPUT = 0x3000032
|
||||
ERROR_TIMEOUT = 0x3000033
|
||||
ERROR_SECURITY_GATEWAY_COOKIE_INVALID = 0x3000034
|
||||
ERROR_SECURITY_GATEWAY_COOKIE_REJECTED = 0x3000035
|
||||
ERROR_SECURITY_GATEWAY_AUTH_METHOD = 0x3000037
|
||||
ERROR_SECURITY_USER_PERIOD_AUTH = 0x3000038
|
||||
ERROR_SECURITY_USER_PERIOD_AUTHZ = 0x3000039
|
||||
ERROR_SECURITY_GATEWAY_POLICY = 0x300003B
|
||||
ERROR_SECURITY_SMARTCARD_CERTIFICATE = 0x300003C
|
||||
ERROR_LOGON_FIRST = 0x300003D
|
||||
ERROR_AUTH_LOGON_FIRST = 0x300003E
|
||||
ERROR_SESSION_ENDED = 0x300003F
|
||||
ERROR_SESSION_ENDED_AUTH = 0x3000040
|
||||
ERROR_SECURITY_GATEWAY_NAP = 0x3000041
|
||||
ERROR_COOKIE_SIZE = 0x3000042
|
||||
ERROR_PROXY_CONFIG = 0x3000044
|
||||
ERROR_NO_PERMISSION = 0x3000045
|
||||
ERROR_NO_RESOURCES = 0x3000046
|
||||
ERROR_RESOURCE_ACCESS = 0x3000047
|
||||
ERROR_UPGRADE_CLIENT2 = 0x3000049
|
||||
ERROR_SECURITY_NETWORK_HTTPS = 0x300004A
|
||||
ERROR_TEMP_FAIL = 0x300004B
|
||||
ERROR_SECURITY_USER_MISMATCH = 0x300004C
|
||||
ERROR_AZURE_TOO_MANY = 0x300004D
|
||||
ERROR_MAX_USER = 0x300004E
|
||||
ERROR_AZURE_TRIAL = 0x300004F
|
||||
ERROR_AZURE_EXPIRED = 0x3000050
|
||||
)
|
||||
*/
|
||||
|
||||
/* Common Error Code */
|
||||
const (
|
||||
ERROR_SUCCESS = 0x00000000
|
||||
ERROR_ACCESS_DENIED = 0x00000005
|
||||
E_PROXY_INTERNALERROR = 0x800759D8
|
||||
E_PROXY_RAP_ACCESSDENIED = 0x800759DA
|
||||
E_PROXY_NAP_ACCESSDENIED = 0x800759DB
|
||||
E_PROXY_ALREADYDISCONNECTED = 0x800759DF
|
||||
E_PROXY_QUARANTINE_ACCESSDENIED = 0x800759ED
|
||||
E_PROXY_NOCERTAVAILABLE = 0x800759EE
|
||||
E_PROXY_COOKIE_BADPACKET = 0x800759F7
|
||||
E_PROXY_COOKIE_AUTHENTICATION_ACCESS_DENIED = 0x800759F8
|
||||
E_PROXY_UNSUPPORTED_AUTHENTICATION_METHOD = 0x800759F9
|
||||
E_PROXY_CAPABILITYMISMATCH = 0x800759E9
|
||||
E_PROXY_TS_CONNECTFAILED = 0x000059DD
|
||||
E_PROXY_MAXCONNECTIONSREACHED = 0x000059E6
|
||||
// E_PROXY_INTERNALERROR = 0x000059D8
|
||||
ERROR_GRACEFUL_DISCONNECT = 0x000004CA
|
||||
E_PROXY_NOTSUPPORTED = 0x000059E8
|
||||
SEC_E_LOGON_DENIED = 0x8009030C
|
||||
E_PROXY_SESSIONTIMEOUT = 0x000059F6
|
||||
E_PROXY_REAUTH_AUTHN_FAILED = 0x000059FA
|
||||
E_PROXY_REAUTH_CAP_FAILED = 0x000059FB
|
||||
E_PROXY_REAUTH_RAP_FAILED = 0x000059FC
|
||||
E_PROXY_SDR_NOT_SUPPORTED_BY_TS = 0x000059FD
|
||||
E_PROXY_REAUTH_NAP_FAILED = 0x00005A00
|
||||
E_PROXY_CONNECTIONABORTED = 0x000004D4
|
||||
)
|
||||
228
cmd/rdpgw/protocol/gateway.go
Normal file
228
cmd/rdpgw/protocol/gateway.go
Normal file
@ -0,0 +1,228 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/identity"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/transport"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/patrickmn/go-cache"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
rdgConnectionIdKey = "Rdg-Connection-Id"
|
||||
MethodRDGIN = "RDG_IN_DATA"
|
||||
MethodRDGOUT = "RDG_OUT_DATA"
|
||||
)
|
||||
|
||||
type CheckPAACookieFunc func(context.Context, string) (bool, error)
|
||||
type CheckClientNameFunc func(context.Context, string) (bool, error)
|
||||
type CheckHostFunc func(context.Context, string) (bool, error)
|
||||
|
||||
type Gateway struct {
|
||||
// CheckPAACookie verifies if the PAA cookie sent by the client is valid
|
||||
CheckPAACookie CheckPAACookieFunc
|
||||
|
||||
// CheckClientName verifies if the client name is allowed to connect
|
||||
CheckClientName CheckClientNameFunc
|
||||
|
||||
// CheckHost verifies if the client is allowed to connect to the remote host
|
||||
CheckHost CheckHostFunc
|
||||
|
||||
// RedirectFlags sets what devices the client is allowed to redirect to the remote host
|
||||
RedirectFlags RedirectFlags
|
||||
|
||||
// IdleTimeOut is used to determine when to disconnect clients that have been idle
|
||||
IdleTimeout int
|
||||
|
||||
// SmartCardAuth sets whether to use smart card based authentication
|
||||
SmartCardAuth bool
|
||||
|
||||
// TokenAuth sets whether to use token/cookie based authentication
|
||||
TokenAuth bool
|
||||
|
||||
ReceiveBuf int
|
||||
SendBuf int
|
||||
}
|
||||
|
||||
var upgrader = websocket.Upgrader{}
|
||||
var c = cache.New(5*time.Minute, 10*time.Minute)
|
||||
|
||||
func (g *Gateway) HandleGatewayProtocol(w http.ResponseWriter, r *http.Request) {
|
||||
connectionCache.Set(float64(c.ItemCount()))
|
||||
|
||||
var t *Tunnel
|
||||
|
||||
ctx := r.Context()
|
||||
id := identity.FromRequestCtx(r)
|
||||
|
||||
connId := r.Header.Get(rdgConnectionIdKey)
|
||||
x, found := c.Get(connId)
|
||||
if !found {
|
||||
t = &Tunnel{
|
||||
RDGId: connId,
|
||||
RemoteAddr: id.GetAttribute(identity.AttrRemoteAddr).(string),
|
||||
User: id,
|
||||
}
|
||||
} else {
|
||||
t = x.(*Tunnel)
|
||||
}
|
||||
ctx = context.WithValue(ctx, CtxTunnel, t)
|
||||
|
||||
if r.Method == MethodRDGOUT {
|
||||
if r.Header.Get("Connection") != "upgrade" && r.Header.Get("Upgrade") != "websocket" {
|
||||
g.handleLegacyProtocol(w, r.WithContext(ctx), t)
|
||||
return
|
||||
}
|
||||
r.Method = "GET" // force
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("Cannot upgrade falling back to old protocol: %t", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
err = g.setSendReceiveBuffers(conn.UnderlyingConn())
|
||||
if err != nil {
|
||||
log.Printf("Cannot set send/receive buffers: %t", err)
|
||||
}
|
||||
|
||||
g.handleWebsocketProtocol(ctx, conn, t)
|
||||
} else if r.Method == MethodRDGIN {
|
||||
g.handleLegacyProtocol(w, r.WithContext(ctx), t)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) setSendReceiveBuffers(conn net.Conn) error {
|
||||
if g.SendBuf < 1 && g.ReceiveBuf < 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// conn == tls.Tunnel
|
||||
ptr := reflect.ValueOf(conn)
|
||||
val := reflect.Indirect(ptr)
|
||||
|
||||
if val.Kind() != reflect.Struct {
|
||||
return errors.New("didn't get a struct from conn")
|
||||
}
|
||||
|
||||
// this gets net.Tunnel -> *net.TCPConn -> net.TCPConn
|
||||
ptrConn := val.FieldByName("conn")
|
||||
valConn := reflect.Indirect(ptrConn)
|
||||
if !valConn.IsValid() {
|
||||
return errors.New("cannot find conn field")
|
||||
}
|
||||
valConn = valConn.Elem().Elem()
|
||||
|
||||
// net.FD
|
||||
ptrNetFd := valConn.FieldByName("fd")
|
||||
valNetFd := reflect.Indirect(ptrNetFd)
|
||||
if !valNetFd.IsValid() {
|
||||
return errors.New("cannot find fd field")
|
||||
}
|
||||
|
||||
// pfd member
|
||||
ptrPfd := valNetFd.FieldByName("pfd")
|
||||
valPfd := reflect.Indirect(ptrPfd)
|
||||
if !valPfd.IsValid() {
|
||||
return errors.New("cannot find pfd field")
|
||||
}
|
||||
|
||||
// finally the exported Sysfd
|
||||
ptrSysFd := valPfd.FieldByName("Sysfd")
|
||||
if !ptrSysFd.IsValid() {
|
||||
return errors.New("cannot find Sysfd field")
|
||||
}
|
||||
fd := int(ptrSysFd.Int())
|
||||
|
||||
if g.ReceiveBuf > 0 {
|
||||
err := syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_RCVBUF, g.ReceiveBuf)
|
||||
if err != nil {
|
||||
return wrapSyscallError("setsockopt", err)
|
||||
}
|
||||
}
|
||||
|
||||
if g.SendBuf > 0 {
|
||||
err := syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_SNDBUF, g.SendBuf)
|
||||
if err != nil {
|
||||
return wrapSyscallError("setsockopt", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Gateway) handleWebsocketProtocol(ctx context.Context, c *websocket.Conn, t *Tunnel) {
|
||||
websocketConnections.Inc()
|
||||
defer websocketConnections.Dec()
|
||||
|
||||
inout, _ := transport.NewWS(c)
|
||||
defer inout.Close()
|
||||
|
||||
t.Id = uuid.New().String()
|
||||
t.transportOut = inout
|
||||
t.transportIn = inout
|
||||
t.ConnectedOn = time.Now()
|
||||
|
||||
handler := NewProcessor(g, t)
|
||||
RegisterTunnel(t, handler)
|
||||
defer RemoveTunnel(t)
|
||||
handler.Process(ctx)
|
||||
}
|
||||
|
||||
// The legacy protocol (no websockets) uses an RDG_IN_DATA for client -> server
|
||||
// and RDG_OUT_DATA for server -> client data. The handshakeRequest procedure is a bit different
|
||||
// to ensure the connections do not get cached or terminated by a proxy prematurely.
|
||||
func (g *Gateway) handleLegacyProtocol(w http.ResponseWriter, r *http.Request, t *Tunnel) {
|
||||
log.Printf("Session %s, %t, %t", t.RDGId, t.transportOut != nil, t.transportIn != nil)
|
||||
|
||||
id := identity.FromRequestCtx(r)
|
||||
if r.Method == MethodRDGOUT {
|
||||
out, err := transport.NewLegacy(w)
|
||||
if err != nil {
|
||||
log.Printf("cannot hijack connection to support RDG OUT data channel: %s", err)
|
||||
return
|
||||
}
|
||||
log.Printf("Opening RDGOUT for client %s", id.GetAttribute(identity.AttrClientIp))
|
||||
|
||||
t.transportOut = out
|
||||
out.SendAccept(true)
|
||||
|
||||
c.Set(t.RDGId, t, cache.DefaultExpiration)
|
||||
} else if r.Method == MethodRDGIN {
|
||||
legacyConnections.Inc()
|
||||
defer legacyConnections.Dec()
|
||||
|
||||
in, err := transport.NewLegacy(w)
|
||||
if err != nil {
|
||||
log.Printf("cannot hijack connection to support RDG IN data channel: %s", err)
|
||||
return
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
if t.transportIn == nil {
|
||||
t.Id = uuid.New().String()
|
||||
t.transportIn = in
|
||||
c.Set(t.RDGId, t, cache.DefaultExpiration)
|
||||
|
||||
log.Printf("Opening RDGIN for client %s", id.GetAttribute(identity.AttrClientIp))
|
||||
in.SendAccept(false)
|
||||
|
||||
// read some initial data
|
||||
in.Drain()
|
||||
|
||||
log.Printf("Legacy handshakeRequest done for client %s", id.GetAttribute(identity.AttrClientIp))
|
||||
handler := NewProcessor(g, t)
|
||||
RegisterTunnel(t, handler)
|
||||
defer RemoveTunnel(t)
|
||||
handler.Process(r.Context())
|
||||
}
|
||||
}
|
||||
}
|
||||
32
cmd/rdpgw/protocol/metrics.go
Normal file
32
cmd/rdpgw/protocol/metrics.go
Normal file
@ -0,0 +1,32 @@
|
||||
package protocol
|
||||
|
||||
import "github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
var (
|
||||
connectionCache = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Namespace: "rdpgw",
|
||||
Name: "connection_cache",
|
||||
Help: "The amount of connections in the cache",
|
||||
})
|
||||
|
||||
websocketConnections = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Namespace: "rdpgw",
|
||||
Name: "websocket_connections",
|
||||
Help: "The count of websocket connections",
|
||||
})
|
||||
|
||||
legacyConnections = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Namespace: "rdpgw",
|
||||
Name: "legacy_connections",
|
||||
Help: "The count of legacy https connections",
|
||||
})
|
||||
)
|
||||
|
||||
func init() {
|
||||
prometheus.MustRegister(connectionCache)
|
||||
prometheus.MustRegister(legacyConnections)
|
||||
prometheus.MustRegister(websocketConnections)
|
||||
}
|
||||
376
cmd/rdpgw/protocol/process.go
Normal file
376
cmd/rdpgw/protocol/process.go
Normal file
@ -0,0 +1,376 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/identity"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Processor struct {
|
||||
// gw is the gateway instance on which the connection arrived
|
||||
// Immutable; never nil.
|
||||
gw *Gateway
|
||||
|
||||
// state is the internal state of the processor
|
||||
state int
|
||||
|
||||
// tunnel is the underlying connection with the client
|
||||
tunnel *Tunnel
|
||||
|
||||
// ctl is a channel to control the processor in case of events
|
||||
ctl chan int
|
||||
}
|
||||
|
||||
func NewProcessor(gw *Gateway, tunnel *Tunnel) *Processor {
|
||||
h := &Processor{
|
||||
gw: gw,
|
||||
state: SERVER_STATE_INITIALIZED,
|
||||
tunnel: tunnel,
|
||||
ctl: make(chan int),
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
const tunnelId = 10
|
||||
|
||||
func (p *Processor) Process(ctx context.Context) error {
|
||||
for {
|
||||
pt, sz, pkt, err := p.tunnel.Read()
|
||||
if err != nil {
|
||||
log.Printf("Cannot read message from stream %p", err)
|
||||
return err
|
||||
}
|
||||
|
||||
switch pt {
|
||||
case PKT_TYPE_HANDSHAKE_REQUEST:
|
||||
log.Printf("Client handshakeRequest from %s", p.tunnel.User.GetAttribute(identity.AttrClientIp))
|
||||
if p.state != SERVER_STATE_INITIALIZED {
|
||||
log.Printf("Handshake attempted while in wrong state %d != %d", p.state, SERVER_STATE_INITIALIZED)
|
||||
msg := p.handshakeResponse(0x0, 0x0, 0, E_PROXY_INTERNALERROR)
|
||||
p.tunnel.Write(msg)
|
||||
return fmt.Errorf("%x: wrong state", E_PROXY_INTERNALERROR)
|
||||
}
|
||||
major, minor, _, reqAuth := p.handshakeRequest(pkt)
|
||||
caps, err := p.matchAuth(reqAuth)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
msg := p.handshakeResponse(0x0, 0x0, 0, E_PROXY_CAPABILITYMISMATCH)
|
||||
p.tunnel.Write(msg)
|
||||
return err
|
||||
}
|
||||
msg := p.handshakeResponse(major, minor, caps, ERROR_SUCCESS)
|
||||
p.tunnel.Write(msg)
|
||||
p.state = SERVER_STATE_HANDSHAKE
|
||||
case PKT_TYPE_TUNNEL_CREATE:
|
||||
log.Printf("Tunnel create")
|
||||
if p.state != SERVER_STATE_HANDSHAKE {
|
||||
log.Printf("Tunnel create attempted while in wrong state %d != %d",
|
||||
p.state, SERVER_STATE_HANDSHAKE)
|
||||
msg := p.tunnelResponse(E_PROXY_INTERNALERROR)
|
||||
p.tunnel.Write(msg)
|
||||
return fmt.Errorf("%x: PAA cookie rejected, wrong state", E_PROXY_INTERNALERROR)
|
||||
}
|
||||
_, cookie := p.tunnelRequest(pkt)
|
||||
if p.gw.CheckPAACookie != nil {
|
||||
if ok, _ := p.gw.CheckPAACookie(ctx, cookie); !ok {
|
||||
log.Printf("Invalid PAA cookie received from client %s", p.tunnel.User.GetAttribute(identity.AttrClientIp))
|
||||
msg := p.tunnelResponse(E_PROXY_COOKIE_AUTHENTICATION_ACCESS_DENIED)
|
||||
p.tunnel.Write(msg)
|
||||
return fmt.Errorf("%x: invalid PAA cookie", E_PROXY_COOKIE_AUTHENTICATION_ACCESS_DENIED)
|
||||
}
|
||||
}
|
||||
msg := p.tunnelResponse(ERROR_SUCCESS)
|
||||
p.tunnel.Write(msg)
|
||||
p.state = SERVER_STATE_TUNNEL_CREATE
|
||||
case PKT_TYPE_TUNNEL_AUTH:
|
||||
log.Printf("Tunnel auth")
|
||||
if p.state != SERVER_STATE_TUNNEL_CREATE {
|
||||
log.Printf("Tunnel auth attempted while in wrong state %d != %d",
|
||||
p.state, SERVER_STATE_TUNNEL_CREATE)
|
||||
msg := p.tunnelAuthResponse(E_PROXY_INTERNALERROR)
|
||||
p.tunnel.Write(msg)
|
||||
return fmt.Errorf("%x: Tunnel auth rejected, wrong state", E_PROXY_INTERNALERROR)
|
||||
}
|
||||
client := p.tunnelAuthRequest(pkt)
|
||||
if p.gw.CheckClientName != nil {
|
||||
if ok, _ := p.gw.CheckClientName(ctx, client); !ok {
|
||||
log.Printf("Invalid client name: %s", client)
|
||||
msg := p.tunnelAuthResponse(ERROR_ACCESS_DENIED)
|
||||
p.tunnel.Write(msg)
|
||||
return fmt.Errorf("%x: Tunnel auth rejected, invalid client name", ERROR_ACCESS_DENIED)
|
||||
}
|
||||
}
|
||||
msg := p.tunnelAuthResponse(ERROR_SUCCESS)
|
||||
p.tunnel.Write(msg)
|
||||
p.state = SERVER_STATE_TUNNEL_AUTHORIZE
|
||||
case PKT_TYPE_CHANNEL_CREATE:
|
||||
log.Printf("Channel create")
|
||||
if p.state != SERVER_STATE_TUNNEL_AUTHORIZE {
|
||||
log.Printf("Channel create attempted while in wrong state %d != %d",
|
||||
p.state, SERVER_STATE_TUNNEL_AUTHORIZE)
|
||||
msg := p.channelResponse(E_PROXY_INTERNALERROR)
|
||||
p.tunnel.Write(msg)
|
||||
return fmt.Errorf("%x: Channel create rejected, wrong state", E_PROXY_INTERNALERROR)
|
||||
}
|
||||
server, port := p.channelRequest(pkt)
|
||||
host := net.JoinHostPort(server, strconv.Itoa(int(port)))
|
||||
if p.gw.CheckHost != nil {
|
||||
log.Printf("Verifying %s host connection", host)
|
||||
if ok, _ := p.gw.CheckHost(ctx, host); !ok {
|
||||
log.Printf("Not allowed to connect to %s by policy handler", host)
|
||||
msg := p.channelResponse(E_PROXY_RAP_ACCESSDENIED)
|
||||
p.tunnel.Write(msg)
|
||||
return fmt.Errorf("%x: denied by security policy", E_PROXY_RAP_ACCESSDENIED)
|
||||
}
|
||||
}
|
||||
log.Printf("Establishing connection to RDP server: %s", host)
|
||||
p.tunnel.rwc, err = net.DialTimeout("tcp", host, time.Second*15)
|
||||
if err != nil {
|
||||
log.Printf("Error connecting to %s, %s", host, err)
|
||||
msg := p.channelResponse(E_PROXY_INTERNALERROR)
|
||||
p.tunnel.Write(msg)
|
||||
return err
|
||||
}
|
||||
p.tunnel.TargetServer = host
|
||||
log.Printf("Connection established")
|
||||
msg := p.channelResponse(ERROR_SUCCESS)
|
||||
p.tunnel.Write(msg)
|
||||
|
||||
// Make sure to start the flow from the RDP server first otherwise connections
|
||||
// might hang eventually
|
||||
go forward(p.tunnel.rwc, p.tunnel)
|
||||
p.state = SERVER_STATE_CHANNEL_CREATE
|
||||
case PKT_TYPE_DATA:
|
||||
if p.state < SERVER_STATE_CHANNEL_CREATE {
|
||||
log.Printf("Data received while in wrong state %d != %d", p.state, SERVER_STATE_CHANNEL_CREATE)
|
||||
return errors.New("wrong state")
|
||||
}
|
||||
p.state = SERVER_STATE_OPENED
|
||||
receive(pkt, p.tunnel.rwc)
|
||||
case PKT_TYPE_KEEPALIVE:
|
||||
// keepalives can be received while the channel is not open yet
|
||||
if p.state < SERVER_STATE_CHANNEL_CREATE {
|
||||
log.Printf("Keepalive received while in wrong state %d != %d", p.state, SERVER_STATE_CHANNEL_CREATE)
|
||||
return errors.New("wrong state")
|
||||
}
|
||||
|
||||
// avoid concurrency issues
|
||||
// p.transportIn.Write(createPacket(PKT_TYPE_KEEPALIVE, []byte{}))
|
||||
case PKT_TYPE_CLOSE_CHANNEL:
|
||||
log.Printf("Close channel")
|
||||
if p.state != SERVER_STATE_OPENED {
|
||||
log.Printf("Channel closed while in wrong state %d != %d", p.state, SERVER_STATE_OPENED)
|
||||
return errors.New("wrong state")
|
||||
}
|
||||
msg := p.channelCloseResponse(ERROR_SUCCESS)
|
||||
p.tunnel.Write(msg)
|
||||
p.state = SERVER_STATE_CLOSED
|
||||
return nil
|
||||
default:
|
||||
log.Printf("Unknown packet (size %d): %x", sz, pkt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Creates a packet and is a response to a handshakeRequest request
|
||||
// HTTP_EXTENDED_AUTH_SSPI_NTLM is not supported in Linux
|
||||
// but could be in Windows. However, the NTLM protocol is insecure
|
||||
func (p *Processor) handshakeResponse(major byte, minor byte, caps uint16, errorCode int) []byte {
|
||||
buf := new(bytes.Buffer)
|
||||
binary.Write(buf, binary.LittleEndian, uint32(errorCode)) // error_code
|
||||
buf.Write([]byte{major, minor})
|
||||
binary.Write(buf, binary.LittleEndian, uint16(0)) // server version
|
||||
binary.Write(buf, binary.LittleEndian, uint16(caps)) // extended auth
|
||||
|
||||
return createPacket(PKT_TYPE_HANDSHAKE_RESPONSE, buf.Bytes())
|
||||
}
|
||||
|
||||
func (p *Processor) handshakeRequest(data []byte) (major byte, minor byte, version uint16, extAuth uint16) {
|
||||
r := bytes.NewReader(data)
|
||||
binary.Read(r, binary.LittleEndian, &major)
|
||||
binary.Read(r, binary.LittleEndian, &minor)
|
||||
binary.Read(r, binary.LittleEndian, &version)
|
||||
binary.Read(r, binary.LittleEndian, &extAuth)
|
||||
|
||||
log.Printf("major: %d, minor: %d, version: %d, ext auth: %d", major, minor, version, extAuth)
|
||||
return
|
||||
}
|
||||
|
||||
func (p *Processor) matchAuth(clientAuthCaps uint16) (caps uint16, err error) {
|
||||
if p.gw.SmartCardAuth {
|
||||
caps = caps | HTTP_EXTENDED_AUTH_SC
|
||||
}
|
||||
if p.gw.TokenAuth {
|
||||
caps = caps | HTTP_EXTENDED_AUTH_PAA
|
||||
}
|
||||
|
||||
if caps&clientAuthCaps == 0 && clientAuthCaps > 0 {
|
||||
return 0, fmt.Errorf("%x has no matching capability configured (%x). Did you configure caps? ", clientAuthCaps, caps)
|
||||
}
|
||||
|
||||
if caps > 0 && clientAuthCaps == 0 {
|
||||
return 0, fmt.Errorf("%d caps are required by the server, but the client does not support them", caps)
|
||||
}
|
||||
return caps, nil
|
||||
}
|
||||
|
||||
func (p *Processor) tunnelRequest(data []byte) (caps uint32, cookie string) {
|
||||
var fields uint16
|
||||
|
||||
r := bytes.NewReader(data)
|
||||
|
||||
binary.Read(r, binary.LittleEndian, &caps)
|
||||
binary.Read(r, binary.LittleEndian, &fields)
|
||||
r.Seek(2, io.SeekCurrent)
|
||||
|
||||
if fields == HTTP_TUNNEL_PACKET_FIELD_PAA_COOKIE {
|
||||
var size uint16
|
||||
binary.Read(r, binary.LittleEndian, &size)
|
||||
cookieB := make([]byte, size)
|
||||
r.Read(cookieB)
|
||||
cookie, _ = DecodeUTF16(cookieB)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (p *Processor) tunnelResponse(errorCode int) []byte {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
binary.Write(buf, binary.LittleEndian, uint16(0)) // server version
|
||||
binary.Write(buf, binary.LittleEndian, uint32(errorCode)) // error code
|
||||
binary.Write(buf, binary.LittleEndian, uint16(HTTP_TUNNEL_RESPONSE_FIELD_TUNNEL_ID|HTTP_TUNNEL_RESPONSE_FIELD_CAPS)) // fields present
|
||||
binary.Write(buf, binary.LittleEndian, uint16(0)) // reserved
|
||||
|
||||
// tunnel id (when is it used?)
|
||||
binary.Write(buf, binary.LittleEndian, uint32(tunnelId))
|
||||
|
||||
binary.Write(buf, binary.LittleEndian, uint32(HTTP_CAPABILITY_IDLE_TIMEOUT))
|
||||
|
||||
return createPacket(PKT_TYPE_TUNNEL_RESPONSE, buf.Bytes())
|
||||
}
|
||||
|
||||
func (p *Processor) tunnelAuthRequest(data []byte) string {
|
||||
buf := bytes.NewReader(data)
|
||||
|
||||
var size uint16
|
||||
binary.Read(buf, binary.LittleEndian, &size)
|
||||
clData := make([]byte, size)
|
||||
binary.Read(buf, binary.LittleEndian, &clData)
|
||||
clientName, _ := DecodeUTF16(clData)
|
||||
|
||||
return clientName
|
||||
}
|
||||
|
||||
func (p *Processor) tunnelAuthResponse(errorCode int) []byte {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
binary.Write(buf, binary.LittleEndian, uint32(errorCode)) // error code
|
||||
binary.Write(buf, binary.LittleEndian, uint16(HTTP_TUNNEL_AUTH_RESPONSE_FIELD_REDIR_FLAGS|HTTP_TUNNEL_AUTH_RESPONSE_FIELD_IDLE_TIMEOUT)) // fields present
|
||||
binary.Write(buf, binary.LittleEndian, uint16(0)) // reserved
|
||||
|
||||
// idle timeout
|
||||
if p.gw.IdleTimeout < 0 {
|
||||
p.gw.IdleTimeout = 0
|
||||
}
|
||||
|
||||
binary.Write(buf, binary.LittleEndian, uint32(makeRedirectFlags(p.gw.RedirectFlags))) // redir flags
|
||||
binary.Write(buf, binary.LittleEndian, uint32(p.gw.IdleTimeout)) // timeout in minutes
|
||||
|
||||
return createPacket(PKT_TYPE_TUNNEL_AUTH_RESPONSE, buf.Bytes())
|
||||
}
|
||||
|
||||
func (p *Processor) channelRequest(data []byte) (server string, port uint16) {
|
||||
buf := bytes.NewReader(data)
|
||||
|
||||
var resourcesSize byte
|
||||
var alternative byte
|
||||
var protocol uint16
|
||||
var nameSize uint16
|
||||
|
||||
binary.Read(buf, binary.LittleEndian, &resourcesSize)
|
||||
binary.Read(buf, binary.LittleEndian, &alternative)
|
||||
binary.Read(buf, binary.LittleEndian, &port)
|
||||
binary.Read(buf, binary.LittleEndian, &protocol)
|
||||
binary.Read(buf, binary.LittleEndian, &nameSize)
|
||||
|
||||
nameData := make([]byte, nameSize)
|
||||
binary.Read(buf, binary.LittleEndian, &nameData)
|
||||
|
||||
server, _ = DecodeUTF16(nameData)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (p *Processor) channelResponse(errorCode int) []byte {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
binary.Write(buf, binary.LittleEndian, uint32(errorCode)) // error code
|
||||
binary.Write(buf, binary.LittleEndian, uint16(HTTP_CHANNEL_RESPONSE_FIELD_CHANNELID)) // fields present
|
||||
binary.Write(buf, binary.LittleEndian, uint16(0)) // reserved
|
||||
|
||||
// channel id is required for Windows clients
|
||||
binary.Write(buf, binary.LittleEndian, uint32(1)) // channel id
|
||||
|
||||
// optional fields
|
||||
// channel id uint32 (4)
|
||||
// udp port uint16 (2)
|
||||
// udp auth cookie 1 byte for side channel
|
||||
// length uint16
|
||||
|
||||
return createPacket(PKT_TYPE_CHANNEL_RESPONSE, buf.Bytes())
|
||||
}
|
||||
|
||||
func (p *Processor) channelCloseResponse(errorCode int) []byte {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
binary.Write(buf, binary.LittleEndian, uint32(errorCode)) // error code
|
||||
binary.Write(buf, binary.LittleEndian, uint16(HTTP_CHANNEL_RESPONSE_FIELD_CHANNELID)) // fields present
|
||||
binary.Write(buf, binary.LittleEndian, uint16(0)) // reserved
|
||||
|
||||
// channel id is required for Windows clients
|
||||
binary.Write(buf, binary.LittleEndian, uint32(1)) // channel id
|
||||
|
||||
// optional fields
|
||||
// channel id uint32 (4)
|
||||
// udp port uint16 (2)
|
||||
// udp auth cookie 1 byte for side channel
|
||||
// length uint16
|
||||
|
||||
return createPacket(PKT_TYPE_CLOSE_CHANNEL_RESPONSE, buf.Bytes())
|
||||
}
|
||||
|
||||
func makeRedirectFlags(flags RedirectFlags) int {
|
||||
var redir = 0
|
||||
|
||||
if flags.DisableAll {
|
||||
return HTTP_TUNNEL_REDIR_DISABLE_ALL
|
||||
}
|
||||
if flags.EnableAll {
|
||||
return HTTP_TUNNEL_REDIR_ENABLE_ALL
|
||||
}
|
||||
|
||||
if !flags.Port {
|
||||
redir = redir | HTTP_TUNNEL_REDIR_DISABLE_PORT
|
||||
}
|
||||
if !flags.Clipboard {
|
||||
redir = redir | HTTP_TUNNEL_REDIR_DISABLE_CLIPBOARD
|
||||
}
|
||||
if !flags.Drive {
|
||||
redir = redir | HTTP_TUNNEL_REDIR_DISABLE_DRIVE
|
||||
}
|
||||
if !flags.Pnp {
|
||||
redir = redir | HTTP_TUNNEL_REDIR_DISABLE_PNP
|
||||
}
|
||||
if !flags.Printer {
|
||||
redir = redir | HTTP_TUNNEL_REDIR_DISABLE_PRINTER
|
||||
}
|
||||
return redir
|
||||
}
|
||||
@ -14,8 +14,8 @@ const (
|
||||
TunnelCreateResponseLen = HeaderLen + 18
|
||||
TunnelAuthLen = HeaderLen + 2 // + dynamic
|
||||
TunnelAuthResponseLen = HeaderLen + 16
|
||||
ChannelCreateLen = HeaderLen + 8 // + dynamic
|
||||
ChannelResponseLen = HeaderLen + 12
|
||||
ChannelCreateLen = HeaderLen + 8 // + dynamic
|
||||
ChannelResponseLen = HeaderLen + 12
|
||||
)
|
||||
|
||||
func verifyPacketHeader(data []byte, expPt uint16, expSize uint32) (uint16, uint32, []byte, error) {
|
||||
@ -40,11 +40,10 @@ func TestHandshake(t *testing.T) {
|
||||
client := ClientConfig{
|
||||
PAAToken: "abab",
|
||||
}
|
||||
s := &SessionInfo{}
|
||||
hc := &ServerConf{
|
||||
TokenAuth: true,
|
||||
}
|
||||
h := NewServer(s, hc)
|
||||
gw := &Gateway{}
|
||||
tunnel := &Tunnel{}
|
||||
|
||||
h := NewProcessor(gw, tunnel)
|
||||
|
||||
data := client.handshakeRequest()
|
||||
|
||||
@ -66,7 +65,7 @@ func TestHandshake(t *testing.T) {
|
||||
t.Fatalf("handshakeRequest failed got ext auth %d, expected %d", extAuth, extAuth|HTTP_EXTENDED_AUTH_PAA)
|
||||
}
|
||||
|
||||
data = h.handshakeResponse(0x0, 0x0)
|
||||
data = h.handshakeResponse(0x0, 0x0, HTTP_EXTENDED_AUTH_PAA, ERROR_SUCCESS)
|
||||
_, _, pkt, err = verifyPacketHeader(data, PKT_TYPE_HANDSHAKE_RESPONSE, HandshakeResponseLen)
|
||||
if err != nil {
|
||||
t.Fatalf("verifyHeader failed: %s", err)
|
||||
@ -79,15 +78,63 @@ func TestHandshake(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func capsHelper(gw Gateway) uint16 {
|
||||
var caps uint16
|
||||
if gw.TokenAuth {
|
||||
caps = caps | HTTP_EXTENDED_AUTH_PAA
|
||||
}
|
||||
if gw.SmartCardAuth {
|
||||
caps = caps | HTTP_EXTENDED_AUTH_SC
|
||||
}
|
||||
return caps
|
||||
}
|
||||
|
||||
func TestMatchAuth(t *testing.T) {
|
||||
gw := &Gateway{}
|
||||
tunnel := &Tunnel{}
|
||||
|
||||
h := NewProcessor(gw, tunnel)
|
||||
|
||||
in := uint16(0)
|
||||
caps, err := h.matchAuth(in)
|
||||
if err != nil {
|
||||
t.Fatalf("in caps: %x <= server caps %x, but %s", in, capsHelper(*gw), err)
|
||||
}
|
||||
if caps > in {
|
||||
t.Fatalf("returned server caps %x > client cpas %x", capsHelper(*gw), in)
|
||||
}
|
||||
|
||||
in = HTTP_EXTENDED_AUTH_PAA
|
||||
caps, err = h.matchAuth(in)
|
||||
if err == nil {
|
||||
t.Fatalf("server cannot satisfy client caps %x but error is nil (server caps %x)", in, caps)
|
||||
} else {
|
||||
t.Logf("(SUCCESS) server cannot satisfy client caps : %s", err)
|
||||
}
|
||||
|
||||
gw.SmartCardAuth = true
|
||||
caps, err = h.matchAuth(in)
|
||||
if err == nil {
|
||||
t.Fatalf("server cannot satisfy client caps %x but error is nil (server caps %x)", in, caps)
|
||||
} else {
|
||||
t.Logf("(SUCCESS) server cannot satisfy client caps : %s", err)
|
||||
}
|
||||
|
||||
gw.TokenAuth = true
|
||||
caps, err = h.matchAuth(in)
|
||||
if err != nil {
|
||||
t.Fatalf("server caps %x (orig: %x) should match client request %x, %s", caps, capsHelper(*gw), in, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnelCreation(t *testing.T) {
|
||||
client := ClientConfig{
|
||||
PAAToken: "abab",
|
||||
}
|
||||
s := &SessionInfo{}
|
||||
hc := &ServerConf{
|
||||
TokenAuth: true,
|
||||
}
|
||||
h := NewServer(s, hc)
|
||||
gw := &Gateway{TokenAuth: true}
|
||||
tunnel := &Tunnel{}
|
||||
|
||||
h := NewProcessor(gw, tunnel)
|
||||
|
||||
data := client.tunnelRequest()
|
||||
_, _, pkt, err := verifyPacketHeader(data, PKT_TYPE_TUNNEL_CREATE,
|
||||
@ -104,7 +151,7 @@ func TestTunnelCreation(t *testing.T) {
|
||||
t.Fatalf("tunnelRequest failed got token %s, expected %s", token, client.PAAToken)
|
||||
}
|
||||
|
||||
data = h.tunnelResponse()
|
||||
data = h.tunnelResponse(ERROR_SUCCESS)
|
||||
_, _, pkt, err = verifyPacketHeader(data, PKT_TYPE_TUNNEL_RESPONSE, TunnelCreateResponseLen)
|
||||
if err != nil {
|
||||
t.Fatalf("verifyHeader failed: %s", err)
|
||||
@ -127,15 +174,13 @@ func TestTunnelAuth(t *testing.T) {
|
||||
client := ClientConfig{
|
||||
Name: name,
|
||||
}
|
||||
s := &SessionInfo{}
|
||||
hc := &ServerConf{
|
||||
TokenAuth: true,
|
||||
IdleTimeout: 10,
|
||||
RedirectFlags: RedirectFlags{
|
||||
Clipboard: true,
|
||||
},
|
||||
gw := &Gateway{
|
||||
TokenAuth: true,
|
||||
IdleTimeout: 10,
|
||||
RedirectFlags: RedirectFlags{Clipboard: true},
|
||||
}
|
||||
h := NewServer(s, hc)
|
||||
tunnel := &Tunnel{}
|
||||
h := NewProcessor(gw, tunnel)
|
||||
|
||||
data := client.tunnelAuthRequest()
|
||||
_, _, pkt, err := verifyPacketHeader(data, PKT_TYPE_TUNNEL_AUTH, uint32(TunnelAuthLen+len(name)*2))
|
||||
@ -148,7 +193,7 @@ func TestTunnelAuth(t *testing.T) {
|
||||
t.Fatalf("tunnelAuthRequest failed got name %s, expected %s", n, name)
|
||||
}
|
||||
|
||||
data = h.tunnelAuthResponse()
|
||||
data = h.tunnelAuthResponse(ERROR_SUCCESS)
|
||||
_, _, pkt, err = verifyPacketHeader(data, PKT_TYPE_TUNNEL_AUTH_RESPONSE, TunnelAuthResponseLen)
|
||||
if err != nil {
|
||||
t.Fatalf("verifyHeader failed: %s", err)
|
||||
@ -161,9 +206,9 @@ func TestTunnelAuth(t *testing.T) {
|
||||
t.Fatalf("tunnelAuthResponse failed got flags %d, expected %d",
|
||||
flags, flags|HTTP_TUNNEL_REDIR_DISABLE_CLIPBOARD)
|
||||
}
|
||||
if int(timeout) != hc.IdleTimeout {
|
||||
if int(timeout) != gw.IdleTimeout {
|
||||
t.Fatalf("tunnelAuthResponse failed got timeout %d, expected %d",
|
||||
timeout, hc.IdleTimeout)
|
||||
timeout, gw.IdleTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
@ -171,17 +216,17 @@ func TestChannelCreation(t *testing.T) {
|
||||
server := "test_server"
|
||||
client := ClientConfig{
|
||||
Server: server,
|
||||
Port: 3389,
|
||||
Port: 3389,
|
||||
}
|
||||
s := &SessionInfo{}
|
||||
hc := &ServerConf{
|
||||
gw := &Gateway{
|
||||
TokenAuth: true,
|
||||
IdleTimeout: 10,
|
||||
RedirectFlags: RedirectFlags{
|
||||
Clipboard: true,
|
||||
},
|
||||
}
|
||||
h := NewServer(s, hc)
|
||||
tunnel := &Tunnel{}
|
||||
h := NewProcessor(gw, tunnel)
|
||||
|
||||
data := client.channelRequest()
|
||||
_, _, pkt, err := verifyPacketHeader(data, PKT_TYPE_CHANNEL_CREATE, uint32(ChannelCreateLen+len(server)*2))
|
||||
@ -196,7 +241,7 @@ func TestChannelCreation(t *testing.T) {
|
||||
t.Fatalf("channelRequest failed got port %d, expected %d", hPort, client.Port)
|
||||
}
|
||||
|
||||
data = h.channelResponse()
|
||||
data = h.channelResponse(ERROR_SUCCESS)
|
||||
_, _, pkt, err = verifyPacketHeader(data, PKT_TYPE_CHANNEL_RESPONSE, uint32(ChannelResponseLen))
|
||||
if err != nil {
|
||||
t.Fatalf("verifyHeader failed: %s", err)
|
||||
101
cmd/rdpgw/protocol/track.go
Normal file
101
cmd/rdpgw/protocol/track.go
Normal file
@ -0,0 +1,101 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
var Connections map[string]*Monitor
|
||||
|
||||
type Monitor struct {
|
||||
Processor *Processor
|
||||
Tunnel *Tunnel
|
||||
}
|
||||
|
||||
const (
|
||||
ctlDisconnect = -1
|
||||
)
|
||||
|
||||
func RegisterTunnel(t *Tunnel, p *Processor) {
|
||||
if Connections == nil {
|
||||
Connections = make(map[string]*Monitor)
|
||||
}
|
||||
|
||||
Connections[t.Id] = &Monitor{
|
||||
Processor: p,
|
||||
Tunnel: t,
|
||||
}
|
||||
}
|
||||
|
||||
func RemoveTunnel(t *Tunnel) {
|
||||
delete(Connections, t.Id)
|
||||
}
|
||||
|
||||
func Disconnect(id string) error {
|
||||
if Connections == nil {
|
||||
return fmt.Errorf("%s connection does not exist", id)
|
||||
}
|
||||
|
||||
if m, ok := Connections[id]; !ok {
|
||||
m.Processor.ctl <- ctlDisconnect
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("%s connection does not exist", id)
|
||||
}
|
||||
|
||||
// GetActiveConnections 返回所有当前活跃的连接信息
|
||||
func GetActiveConnections() []map[string]interface{} {
|
||||
connections := []map[string]interface{}{}
|
||||
|
||||
for id, monitor := range Connections {
|
||||
tunnel := monitor.Tunnel
|
||||
if tunnel == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 计算连接持续时间
|
||||
duration := time.Since(tunnel.ConnectedOn)
|
||||
|
||||
// 收集每个连接的关键信息
|
||||
connInfo := map[string]interface{}{
|
||||
"id": id,
|
||||
"rdgId": tunnel.RDGId,
|
||||
"targetServer": tunnel.TargetServer,
|
||||
"remoteAddr": tunnel.RemoteAddr,
|
||||
"userName": tunnel.User.UserName(),
|
||||
"domain": tunnel.User.Domain(),
|
||||
"connectedOn": tunnel.ConnectedOn,
|
||||
"lastSeen": tunnel.LastSeen,
|
||||
"bytesSent": tunnel.BytesSent,
|
||||
"bytesReceived": tunnel.BytesReceived,
|
||||
"durationSecs": int(duration.Seconds()),
|
||||
}
|
||||
|
||||
connections = append(connections, connInfo)
|
||||
}
|
||||
|
||||
return connections
|
||||
}
|
||||
|
||||
// CalculateSpeedPerSecond calculate moving average.
|
||||
/*
|
||||
func CalculateSpeedPerSecond(connId string) (in int, out int) {
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
c := Connections[connId]
|
||||
total := int64(0)
|
||||
for _, v := range c.Tunnel.BytesReceived {
|
||||
total += v
|
||||
}
|
||||
in = int(total / (now - c.TimeStamp) * 1000)
|
||||
|
||||
total = int64(0)
|
||||
for _, v := range c.BytesSent {
|
||||
total += v
|
||||
}
|
||||
out = int(total / (now - c.TimeStamp))
|
||||
|
||||
return in, out
|
||||
}
|
||||
*/
|
||||
64
cmd/rdpgw/protocol/tunnel.go
Normal file
64
cmd/rdpgw/protocol/tunnel.go
Normal file
@ -0,0 +1,64 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/identity"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/transport"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
CtxTunnel = "github.com/bolkedebruin/rdpgw/tunnel"
|
||||
)
|
||||
|
||||
type Tunnel struct {
|
||||
// Id identifies the connection in the server
|
||||
Id string
|
||||
// The connection-id (RDG-ConnID) as reported by the client
|
||||
RDGId string
|
||||
// The underlying incoming transport being either websocket or legacy http
|
||||
// in case of websocket transportOut will equal transportIn
|
||||
transportIn transport.Transport
|
||||
// The underlying outgoing transport being either websocket or legacy http
|
||||
// in case of websocket transportOut will equal transportOut
|
||||
transportOut transport.Transport
|
||||
// The remote desktop server (rdp, vnc etc) the clients intends to connect to
|
||||
TargetServer string
|
||||
// The obtained client ip address
|
||||
RemoteAddr string
|
||||
// User
|
||||
User identity.Identity
|
||||
|
||||
// rwc is the underlying connection to the remote desktop server.
|
||||
// It is of the type *net.TCPConn
|
||||
rwc net.Conn
|
||||
|
||||
// BytesSent is the total amount of bytes sent by the server to the client minus tunnel overhead
|
||||
BytesSent int64
|
||||
|
||||
// BytesReceived is the total amount of bytes received by the server from the client minus tunnel overhad
|
||||
BytesReceived int64
|
||||
|
||||
// ConnectedOn is when the client connected to the server
|
||||
ConnectedOn time.Time
|
||||
|
||||
// LastSeen is when the server received the last packet from the client
|
||||
LastSeen time.Time
|
||||
}
|
||||
|
||||
// Write puts the packet on the transport and updates the statistics for bytes sent
|
||||
func (t *Tunnel) Write(pkt []byte) {
|
||||
n, _ := t.transportOut.WritePacket(pkt)
|
||||
t.BytesSent += int64(n)
|
||||
}
|
||||
|
||||
// Read picks up a packet from the transport and returns the packet type
|
||||
// packet, with the header removed, and the packet size. It updates the
|
||||
// statistics for bytes received
|
||||
func (t *Tunnel) Read() (pt int, size int, pkt []byte, err error) {
|
||||
pt, size, pkt, err = readMessage(t.transportIn)
|
||||
t.BytesReceived += int64(size)
|
||||
t.LastSeen = time.Now()
|
||||
|
||||
return pt, size, pkt, err
|
||||
}
|
||||
@ -43,7 +43,7 @@ const (
|
||||
HTTP_TUNNEL_REDIR_DISABLE_ALL = 0x40000000
|
||||
HTTP_TUNNEL_REDIR_DISABLE_DRIVE = 0x01
|
||||
HTTP_TUNNEL_REDIR_DISABLE_PRINTER = 0x02
|
||||
HTTP_TUNNEL_REDIR_DISABLE_PORT = 0x03
|
||||
HTTP_TUNNEL_REDIR_DISABLE_PORT = 0x04
|
||||
HTTP_TUNNEL_REDIR_DISABLE_CLIPBOARD = 0x08
|
||||
HTTP_TUNNEL_REDIR_DISABLE_PNP = 0x10
|
||||
)
|
||||
@ -59,8 +59,8 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
SERVER_STATE_INITIAL = 0x0
|
||||
SERVER_STATE_HANDSHAKE = 0x1
|
||||
SERVER_STATE_INITIALIZED = 0x0
|
||||
SERVER_STATE_HANDSHAKE = 0x1
|
||||
SERVER_STATE_TUNNEL_CREATE = 0x2
|
||||
SERVER_STATE_TUNNEL_AUTHORIZE = 0x3
|
||||
SERVER_STATE_CHANNEL_CREATE = 0x4
|
||||
85
cmd/rdpgw/rdp/koanf/parsers/rdp/rdp.go
Normal file
85
cmd/rdpgw/rdp/koanf/parsers/rdp/rdp.go
Normal file
@ -0,0 +1,85 @@
|
||||
package rdp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RDP struct{}
|
||||
|
||||
func Parser() *RDP {
|
||||
return &RDP{}
|
||||
}
|
||||
|
||||
func (p *RDP) Unmarshal(b []byte) (map[string]interface{}, error) {
|
||||
r := bytes.NewReader(b)
|
||||
scanner := bufio.NewScanner(r)
|
||||
mp := make(map[string]interface{})
|
||||
|
||||
c := 0
|
||||
for scanner.Scan() {
|
||||
c++
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
fields := strings.SplitN(line, ":", 3)
|
||||
if len(fields) != 3 {
|
||||
return nil, fmt.Errorf("malformed line %d: %q", c, line)
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(fields[0])
|
||||
t := strings.TrimSpace(fields[1])
|
||||
val := strings.TrimSpace(fields[2])
|
||||
|
||||
switch t {
|
||||
case "i":
|
||||
intValue, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse integer at line %d: %s", c, line)
|
||||
}
|
||||
mp[key] = intValue
|
||||
case "s":
|
||||
mp[key] = val
|
||||
case "b":
|
||||
mp[key] = val
|
||||
default:
|
||||
return nil, fmt.Errorf("malformed line %d: %s", c, line)
|
||||
}
|
||||
}
|
||||
return mp, nil
|
||||
}
|
||||
|
||||
func (p *RDP) Marshal(o map[string]interface{}) ([]byte, error) {
|
||||
var b bytes.Buffer
|
||||
|
||||
keys := make([]string, 0, len(o))
|
||||
for k := range o {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
for _, key := range keys {
|
||||
v := o[key]
|
||||
switch v.(type) {
|
||||
case bool:
|
||||
if v == true {
|
||||
fmt.Fprintf(&b, "%s:i:1", key)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "%s:i:0", key)
|
||||
}
|
||||
case int:
|
||||
fmt.Fprintf(&b, "%s:i:%d", key, v)
|
||||
case string:
|
||||
fmt.Fprintf(&b, "%s:s:%s", key, v)
|
||||
default:
|
||||
return nil, fmt.Errorf("error marshalling")
|
||||
}
|
||||
fmt.Fprint(&b, "\r\n")
|
||||
}
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
85
cmd/rdpgw/rdp/koanf/parsers/rdp/rdp_test.go
Normal file
85
cmd/rdpgw/rdp/koanf/parsers/rdp/rdp_test.go
Normal file
@ -0,0 +1,85 @@
|
||||
package rdp
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUnmarshalRDPFile(t *testing.T) {
|
||||
rdp := Parser()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
cfg []byte
|
||||
expOutput map[string]interface{}
|
||||
err error
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
expOutput: map[string]interface{}{},
|
||||
},
|
||||
{
|
||||
name: "string",
|
||||
cfg: []byte(`username:s:user1`),
|
||||
expOutput: map[string]interface{}{
|
||||
"username": "user1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "integer",
|
||||
cfg: []byte(`session bpp:i:32`),
|
||||
expOutput: map[string]interface{}{
|
||||
"session bpp": 32,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multi",
|
||||
cfg: []byte("compression:i:1\r\nusername:s:user2\r\n"),
|
||||
expOutput: map[string]interface{}{
|
||||
"compression": 1,
|
||||
"username": "user2",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
outMap, err := rdp.Unmarshal(tc.cfg)
|
||||
assert.Equal(t, tc.err, err)
|
||||
assert.Equal(t, tc.expOutput, outMap)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRDP_Marshal(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input map[string]interface{}
|
||||
output []byte
|
||||
err error
|
||||
}{
|
||||
{
|
||||
name: "Empty RDP",
|
||||
input: map[string]interface{}{},
|
||||
output: []byte(nil),
|
||||
},
|
||||
{
|
||||
name: "Valid RDP all types",
|
||||
input: map[string]interface{}{
|
||||
"compression": 1,
|
||||
"session bpp": 32,
|
||||
"username": "user1",
|
||||
},
|
||||
output: []byte("compression:i:1\r\nsession bpp:i:32\r\nusername:s:user1\r\n"),
|
||||
},
|
||||
}
|
||||
|
||||
rdp := Parser()
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := rdp.Marshal(tc.input)
|
||||
assert.Equal(t, tc.output, out)
|
||||
assert.Equal(t, tc.err, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
253
cmd/rdpgw/rdp/rdp.go
Normal file
253
cmd/rdpgw/rdp/rdp.go
Normal file
@ -0,0 +1,253 @@
|
||||
package rdp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/rdp/koanf/parsers/rdp"
|
||||
"github.com/fatih/structs"
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
"github.com/knadh/koanf/providers/file"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"log"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
CRLF = "\r\n"
|
||||
)
|
||||
|
||||
const (
|
||||
SourceNTLM int = iota
|
||||
SourceSmartCard
|
||||
SourceCurrent
|
||||
SourceBasic
|
||||
SourceUserSelect
|
||||
SourceCookie
|
||||
)
|
||||
|
||||
type RdpSettings struct {
|
||||
AllowFontSmoothing bool `rdp:"allow font smoothing" default:"0"`
|
||||
AllowDesktopComposition bool `rdp:"allow desktop composition" default:"0"`
|
||||
DisableFullWindowDrag bool `rdp:"disable full window drag" default:"0"`
|
||||
DisableMenuAnims bool `rdp:"disable menu anims" default:"0"`
|
||||
DisableThemes bool `rdp:"disable themes" default:"0"`
|
||||
DisableCursorSetting bool `rdp:"disable cursor setting" default:"0"`
|
||||
GatewayHostname string `rdp:"gatewayhostname"`
|
||||
FullAddress string `rdp:"full address"`
|
||||
AlternateFullAddress string `rdp:"alternate full address"`
|
||||
Username string `rdp:"username"`
|
||||
Domain string `rdp:"domain"`
|
||||
GatewayCredentialsSource int `rdp:"gatewaycredentialssource" default:"0"`
|
||||
GatewayCredentialMethod int `rdp:"gatewayprofileusagemethod" default:"0"`
|
||||
GatewayUsageMethod int `rdp:"gatewayusagemethod" default:"0"`
|
||||
GatewayAccessToken string `rdp:"gatewayaccesstoken"`
|
||||
PromptCredentialsOnce bool `rdp:"promptcredentialonce" default:"true"`
|
||||
AuthenticationLevel int `rdp:"authentication level" default:"3"`
|
||||
EnableCredSSPSupport bool `rdp:"enablecredsspsupport" default:"true"`
|
||||
EnableRdsAasAuth bool `rdp:"enablerdsaadauth" default:"false"`
|
||||
DisableConnectionSharing bool `rdp:"disableconnectionsharing" default:"false"`
|
||||
AlternateShell string `rdp:"alternate shell"`
|
||||
AutoReconnectionEnabled bool `rdp:"autoreconnectionenabled" default:"true"`
|
||||
BandwidthAutodetect bool `rdp:"bandwidthautodetect" default:"true"`
|
||||
NetworkAutodetect bool `rdp:"networkautodetect" default:"true"`
|
||||
Compression bool `rdp:"compression" default:"true"`
|
||||
VideoPlaybackMode bool `rdp:"videoplaybackmode" default:"true"`
|
||||
ConnectionType int `rdp:"connection type" default:"2"`
|
||||
AudioCaptureMode bool `rdp:"audiocapturemode" default:"false"`
|
||||
EncodeRedirectedVideoCapture bool `rdp:"encode redirected video capture" default:"true"`
|
||||
RedirectedVideoCaptureEncodingQuality int `rdp:"redirected video capture encoding quality" default:"0"`
|
||||
AudioMode int `rdp:"audiomode" default:"0"`
|
||||
CameraStoreRedirect string `rdp:"camerastoredirect" default:"false"`
|
||||
DeviceStoreRedirect string `rdp:"devicestoredirect" default:"false"`
|
||||
DriveStoreRedirect string `rdp:"drivestoredirect" default:"false"`
|
||||
KeyboardHook int `rdp:"keyboardhook" default:"2"`
|
||||
RedirectClipboard bool `rdp:"redirectclipboard" default:"true"`
|
||||
RedirectComPorts bool `rdp:"redirectcomports" default:"false"`
|
||||
RedirectLocation bool `rdp:"redirectlocation" default:"false"`
|
||||
RedirectPrinters bool `rdp:"redirectprinters" default:"true"`
|
||||
RedirectSmartcards bool `rdp:"redirectsmartcards" default:"true"`
|
||||
RedirectWebAuthn bool `rdp:"redirectwebauthn" default:"true"`
|
||||
UsbDeviceStoRedirect string `rdp:"usbdevicestoredirect"`
|
||||
UseMultimon bool `rdp:"use multimon" default:"false"`
|
||||
SelectedMonitors string `rdp:"selectedmonitors"`
|
||||
MaximizeToCurrentDisplays bool `rdp:"maximizetocurrentdisplays" default:"false"`
|
||||
SingleMonInWindowedMode bool `rdp:"singlemoninwindowedmode" default:"0"`
|
||||
ScreenModeId int `rdp:"screen mode id" default:"2"`
|
||||
SmartSizing bool `rdp:"smart sizing" default:"false"`
|
||||
DynamicResolution bool `rdp:"dynamic resolution" default:"true"`
|
||||
DesktopSizeId int `rdp:"desktop size id"`
|
||||
DesktopHeight int `rdp:"desktopheight"`
|
||||
DesktopWidth int `rdp:"desktopwidth"`
|
||||
DesktopScaleFactor int `rdp:"desktopscalefactor"`
|
||||
BitmapCacheSize int `rdp:"bitmapcachesize" default:"1500"`
|
||||
BitmapCachePersistEnable bool `rdp:"bitmapcachepersistenable" default:"true"`
|
||||
RemoteApplicationCmdLine string `rdp:"remoteapplicationcmdline"`
|
||||
RemoteAppExpandWorkingDir bool `rdp:"remoteapplicationexpandworkingdir" default:"true"`
|
||||
RemoteApplicationFile string `rdp:"remoteapplicationfile" default:"true"`
|
||||
RemoteApplicationIcon string `rdp:"remoteapplicationicon"`
|
||||
RemoteApplicationMode bool `rdp:"remoteapplicationmode" default:"false"`
|
||||
RemoteApplicationName string `rdp:"remoteapplicationname"`
|
||||
RemoteApplicationProgram string `rdp:"remoteapplicationprogram"`
|
||||
}
|
||||
|
||||
type Builder struct {
|
||||
Settings RdpSettings
|
||||
Metadata mapstructure.Metadata
|
||||
}
|
||||
|
||||
func NewBuilder() *Builder {
|
||||
c := RdpSettings{}
|
||||
|
||||
initStruct(&c)
|
||||
|
||||
return &Builder{
|
||||
Settings: c,
|
||||
Metadata: mapstructure.Metadata{},
|
||||
}
|
||||
}
|
||||
|
||||
func NewBuilderFromFile(filename string) (*Builder, error) {
|
||||
c := RdpSettings{}
|
||||
initStruct(&c)
|
||||
metadata := mapstructure.Metadata{}
|
||||
|
||||
decoderConfig := &mapstructure.DecoderConfig{
|
||||
Result: &c,
|
||||
Metadata: &metadata,
|
||||
WeaklyTypedInput: true,
|
||||
}
|
||||
|
||||
var k = koanf.New(".")
|
||||
if err := k.Load(file.Provider(filename), rdp.Parser()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t := koanf.UnmarshalConf{Tag: "rdp", DecoderConfig: decoderConfig}
|
||||
|
||||
if err := k.UnmarshalWithConf("", &c, t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Builder{
|
||||
Settings: c,
|
||||
Metadata: metadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (rb *Builder) String() string {
|
||||
var sb strings.Builder
|
||||
|
||||
addStructToString(rb.Settings, rb.Metadata, &sb)
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func addStructToString(st interface{}, metadata mapstructure.Metadata, sb *strings.Builder) {
|
||||
s := structs.New(st)
|
||||
for _, f := range s.Fields() {
|
||||
if isZero(f) && !isSet(f, metadata) {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(f.Tag("rdp"))
|
||||
sb.WriteString(":")
|
||||
|
||||
switch f.Kind() {
|
||||
case reflect.String:
|
||||
sb.WriteString("s:")
|
||||
sb.WriteString(f.Value().(string))
|
||||
case reflect.Int:
|
||||
sb.WriteString("i:")
|
||||
fmt.Fprintf(sb, "%d", f.Value())
|
||||
case reflect.Bool:
|
||||
sb.WriteString("i:")
|
||||
if f.Value().(bool) {
|
||||
sb.WriteString("1")
|
||||
} else {
|
||||
sb.WriteString("0")
|
||||
|
||||
}
|
||||
}
|
||||
sb.WriteString(CRLF)
|
||||
}
|
||||
}
|
||||
|
||||
func isZero(f *structs.Field) bool {
|
||||
t := f.Tag("default")
|
||||
if t == "" {
|
||||
return f.IsZero()
|
||||
}
|
||||
|
||||
switch f.Kind() {
|
||||
case reflect.String:
|
||||
if f.Value().(string) != t {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case reflect.Int:
|
||||
i, err := strconv.Atoi(t)
|
||||
if err != nil {
|
||||
log.Fatalf("runtime error: default %s is not an integer", t)
|
||||
}
|
||||
if f.Value().(int) != i {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case reflect.Bool:
|
||||
b := false
|
||||
if t == "true" || t == "1" {
|
||||
b = true
|
||||
}
|
||||
if f.Value().(bool) != b {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return f.IsZero()
|
||||
}
|
||||
|
||||
func isSet(f *structs.Field, metadata mapstructure.Metadata) bool {
|
||||
for _, v := range metadata.Unset {
|
||||
if v == f.Name() {
|
||||
log.Printf("field %s is unset", f.Name())
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func initStruct(st interface{}) {
|
||||
s := structs.New(st)
|
||||
for _, f := range s.Fields() {
|
||||
t := f.Tag("default")
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
err := setVariable(f, t)
|
||||
if err != nil {
|
||||
log.Fatalf("cannot init rdp struct: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setVariable(f *structs.Field, v string) error {
|
||||
switch f.Kind() {
|
||||
case reflect.String:
|
||||
return f.Set(v)
|
||||
case reflect.Int:
|
||||
i, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Set(i)
|
||||
case reflect.Bool:
|
||||
b := false
|
||||
if v == "true" || v == "1" {
|
||||
b = true
|
||||
}
|
||||
return f.Set(b)
|
||||
default:
|
||||
return errors.New("invalid field type")
|
||||
}
|
||||
}
|
||||
47
cmd/rdpgw/rdp/rdp_test.go
Normal file
47
cmd/rdpgw/rdp/rdp_test.go
Normal file
@ -0,0 +1,47 @@
|
||||
package rdp
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
GatewayHostName = "my.yahoo.com"
|
||||
)
|
||||
|
||||
func TestRdpBuilder(t *testing.T) {
|
||||
builder := NewBuilder()
|
||||
builder.Settings.GatewayHostname = "my.yahoo.com"
|
||||
builder.Settings.AutoReconnectionEnabled = true
|
||||
builder.Settings.SmartSizing = true
|
||||
|
||||
s := builder.String()
|
||||
if !strings.Contains(s, "gatewayhostname:s:"+GatewayHostName+CRLF) {
|
||||
t.Fatalf("%s does not contain `gatewayhostname:s:%s", s, GatewayHostName)
|
||||
}
|
||||
if strings.Contains(s, "autoreconnectionenabled") {
|
||||
t.Fatalf("autoreconnectionenabled is in %s, but it's default value", s)
|
||||
}
|
||||
if !strings.Contains(s, "smart sizing:i:1"+CRLF) {
|
||||
t.Fatalf("%s does not contain smart sizing:i:1", s)
|
||||
|
||||
}
|
||||
log.Printf(builder.String())
|
||||
}
|
||||
|
||||
func TestInitStruct(t *testing.T) {
|
||||
conn := RdpSettings{}
|
||||
initStruct(&conn)
|
||||
|
||||
if conn.PromptCredentialsOnce != true {
|
||||
t.Fatalf("conn.PromptCredentialsOnce != true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFile(t *testing.T) {
|
||||
_, err := NewBuilderFromFile("rdp_test_file.rdp")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFile failed: %v", err)
|
||||
}
|
||||
}
|
||||
37
cmd/rdpgw/rdp/rdp_test_file.rdp
Normal file
37
cmd/rdpgw/rdp/rdp_test_file.rdp
Normal file
@ -0,0 +1,37 @@
|
||||
Password:b:0200000000000000000000000000000000000000000000000800000072006400700000000E660000100000001000000031A2D4A21767565E3A268420A9397C4400000000048000001000000010000000A56C359BBBA13EC284391427E6A107BD20000000333E6F6DA024E1B6B4CC7DDF57BFC1783ED02F212B8FBD39997C888F9D4B438914000000A80D19234BA4CC5CE2695A34EF0B9B92D5D777A6
|
||||
ColorDepthID:i:1
|
||||
ScreenStyle:i:0
|
||||
DesktopWidth:i:640
|
||||
DesktopHeight:i:480
|
||||
UserName:s:rdesktop
|
||||
SavePassword:i:1
|
||||
Keyboard Layout:s:00000409
|
||||
BitmapPersistCacheSize:i:1
|
||||
BitmapCacheSize:i:21
|
||||
KeyboardFunctionKey:i:12
|
||||
KeyboardSubType:i:0
|
||||
KeyboardType:i:4
|
||||
KeyboardLayoutString:s:0xE0010409
|
||||
Disable Themes:i:0
|
||||
Disable Menu Anims:i:1
|
||||
Disable Full Window Drag:i:1
|
||||
Disable Wallpaper:i:1
|
||||
MaxReconnectAttempts:i:20
|
||||
KeyboardHookMode:i:0
|
||||
Compress:i:1
|
||||
BBarShowPinBtn:i:0
|
||||
BitmapPersistenceEnabled:i:0
|
||||
AudioRedirectionMode:i:2
|
||||
EnablePortRedirection:i:0
|
||||
EnableDriveRedirection:i:0
|
||||
AutoReconnectEnabled:i:1
|
||||
EnableSCardRedirection:i:1
|
||||
EnablePrinterRedirection:i:0
|
||||
BBarEnabled:i:0
|
||||
DisableFileAccess:i:0
|
||||
MinutesToIdleTimeout:i:5
|
||||
GrabFocusOnConnect:i:0
|
||||
StartFullScreen:i:1
|
||||
Domain:s:GE3SDT8KLRL4J
|
||||
enablecredsspsupport:i:0
|
||||
use multimon:i:1
|
||||
40
cmd/rdpgw/security/basic.go
Normal file
40
cmd/rdpgw/security/basic.go
Normal file
@ -0,0 +1,40 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
Hosts []string
|
||||
HostSelection string
|
||||
)
|
||||
|
||||
func CheckHost(ctx context.Context, host string) (bool, error) {
|
||||
switch HostSelection {
|
||||
case "any":
|
||||
return true, nil
|
||||
case "signed":
|
||||
// todo get from context?
|
||||
return false, errors.New("cannot verify host in 'signed' mode as token data is missing")
|
||||
case "roundrobin", "unsigned":
|
||||
s := getTunnel(ctx)
|
||||
if s.User.UserName() == "" {
|
||||
return false, errors.New("no valid session info or username found in context")
|
||||
}
|
||||
|
||||
log.Printf("Checking host for user %s", s.User.UserName())
|
||||
for _, h := range Hosts {
|
||||
h = strings.Replace(h, "{{ preferred_username }}", s.User.UserName(), 1)
|
||||
if h == host {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, fmt.Errorf("invalid host %s", host)
|
||||
}
|
||||
|
||||
return false, errors.New("unrecognized host selection criteria")
|
||||
}
|
||||
50
cmd/rdpgw/security/basic_test.go
Normal file
50
cmd/rdpgw/security/basic_test.go
Normal file
@ -0,0 +1,50 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/identity"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/protocol"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var (
|
||||
info = protocol.Tunnel{
|
||||
RDGId: "myid",
|
||||
TargetServer: "my.remote.server",
|
||||
RemoteAddr: "10.0.0.1",
|
||||
}
|
||||
|
||||
hosts = []string{"localhost:3389", "my-{{ preferred_username }}-host:3389"}
|
||||
)
|
||||
|
||||
func TestCheckHost(t *testing.T) {
|
||||
info.User = identity.NewUser()
|
||||
info.User.SetUserName("MYNAME")
|
||||
|
||||
ctx := context.WithValue(context.Background(), protocol.CtxTunnel, &info)
|
||||
|
||||
Hosts = hosts
|
||||
|
||||
// check any
|
||||
HostSelection = "any"
|
||||
host := "try.my.server:3389"
|
||||
if ok, err := CheckHost(ctx, host); !ok || err != nil {
|
||||
t.Fatalf("%s should be allowed with host selection %s (err: %s)", host, HostSelection, err)
|
||||
}
|
||||
|
||||
HostSelection = "signed"
|
||||
if ok, err := CheckHost(ctx, host); ok || err == nil {
|
||||
t.Fatalf("signed host selection isnt supported at the moment")
|
||||
}
|
||||
|
||||
HostSelection = "roundrobin"
|
||||
if ok, err := CheckHost(ctx, host); ok {
|
||||
t.Fatalf("%s should NOT be allowed with host selection %s (err: %s)", host, HostSelection, err)
|
||||
}
|
||||
|
||||
host = "my-MYNAME-host:3389"
|
||||
if ok, err := CheckHost(ctx, host); !ok {
|
||||
t.Fatalf("%s should be allowed with host selection %s (err: %s)", host, HostSelection, err)
|
||||
}
|
||||
|
||||
}
|
||||
294
cmd/rdpgw/security/jwt.go
Normal file
294
cmd/rdpgw/security/jwt.go
Normal file
@ -0,0 +1,294 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/identity"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/protocol"
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
"golang.org/x/oauth2"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
SigningKey []byte
|
||||
EncryptionKey []byte
|
||||
UserSigningKey []byte
|
||||
UserEncryptionKey []byte
|
||||
QuerySigningKey []byte
|
||||
OIDCProvider *oidc.Provider
|
||||
Oauth2Config oauth2.Config
|
||||
)
|
||||
|
||||
var ExpiryTime time.Duration = 5
|
||||
var VerifyClientIP bool = true
|
||||
|
||||
type customClaims struct {
|
||||
RemoteServer string `json:"remoteServer"`
|
||||
ClientIP string `json:"clientIp"`
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
|
||||
func CheckSession(next protocol.CheckHostFunc) protocol.CheckHostFunc {
|
||||
return func(ctx context.Context, host string) (bool, error) {
|
||||
tunnel := getTunnel(ctx)
|
||||
if tunnel == nil {
|
||||
return false, errors.New("no valid session info found in context")
|
||||
}
|
||||
|
||||
if tunnel.TargetServer != host {
|
||||
log.Printf("Client specified host %s does not match token host %s", host, tunnel.TargetServer)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// use identity from context rather then set by tunnel
|
||||
id := identity.FromCtx(ctx)
|
||||
if VerifyClientIP && tunnel.RemoteAddr != id.GetAttribute(identity.AttrClientIp) {
|
||||
log.Printf("Current client ip address %s does not match token client ip %s",
|
||||
id.GetAttribute(identity.AttrClientIp), tunnel.RemoteAddr)
|
||||
return false, nil
|
||||
}
|
||||
return next(ctx, host)
|
||||
}
|
||||
}
|
||||
|
||||
func CheckPAACookie(ctx context.Context, tokenString string) (bool, error) {
|
||||
if tokenString == "" {
|
||||
log.Printf("no token to parse")
|
||||
return false, errors.New("no token to parse")
|
||||
}
|
||||
|
||||
token, err := jwt.ParseSigned(tokenString, []jose.SignatureAlgorithm{jose.HS256})
|
||||
if err != nil {
|
||||
log.Printf("cannot parse token due to: %t", err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
// check if the signing algo matches what we expect
|
||||
for _, header := range token.Headers {
|
||||
if header.Algorithm != string(jose.HS256) {
|
||||
return false, fmt.Errorf("unexpected signing method: %v", header.Algorithm)
|
||||
}
|
||||
}
|
||||
|
||||
standard := jwt.Claims{}
|
||||
custom := customClaims{}
|
||||
|
||||
// Claims automagically checks the signature...
|
||||
err = token.Claims(SigningKey, &standard, &custom)
|
||||
if err != nil {
|
||||
log.Printf("token signature validation failed due to %tunnel", err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
// ...but doesn't check the expiry claim :/
|
||||
err = standard.Validate(jwt.Expected{
|
||||
Issuer: "rdpgw",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("token validation failed due to %tunnel", err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
// validate the access token
|
||||
tokenSource := Oauth2Config.TokenSource(ctx, &oauth2.Token{AccessToken: custom.AccessToken})
|
||||
user, err := OIDCProvider.UserInfo(ctx, tokenSource)
|
||||
if err != nil {
|
||||
log.Printf("Cannot get user info for access token: %tunnel", err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
tunnel := getTunnel(ctx)
|
||||
|
||||
tunnel.TargetServer = custom.RemoteServer
|
||||
tunnel.RemoteAddr = custom.ClientIP
|
||||
tunnel.User.SetUserName(user.Subject)
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func GeneratePAAToken(ctx context.Context, username string, server string) (string, error) {
|
||||
if len(SigningKey) < 32 {
|
||||
return "", errors.New("token signing key not long enough or not specified")
|
||||
}
|
||||
sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS256, Key: SigningKey}, nil)
|
||||
if err != nil {
|
||||
log.Printf("Cannot obtain signer %s", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
standard := jwt.Claims{
|
||||
Issuer: "rdpgw",
|
||||
Expiry: jwt.NewNumericDate(time.Now().Add(time.Minute * 5)),
|
||||
Subject: username,
|
||||
}
|
||||
|
||||
id := identity.FromCtx(ctx)
|
||||
private := customClaims{
|
||||
RemoteServer: server,
|
||||
ClientIP: id.GetAttribute(identity.AttrClientIp).(string),
|
||||
AccessToken: id.GetAttribute(identity.AttrAccessToken).(string),
|
||||
}
|
||||
|
||||
if token, err := jwt.Signed(sig).Claims(standard).Claims(private).Serialize(); err != nil {
|
||||
log.Printf("Cannot sign PAA token %s", err)
|
||||
return "", err
|
||||
} else {
|
||||
return token, nil
|
||||
}
|
||||
}
|
||||
|
||||
func GenerateUserToken(ctx context.Context, userName string) (string, error) {
|
||||
if len(UserEncryptionKey) < 32 {
|
||||
return "", errors.New("user token encryption key not long enough or not specified")
|
||||
}
|
||||
|
||||
claims := jwt.Claims{
|
||||
Subject: userName,
|
||||
Expiry: jwt.NewNumericDate(time.Now().Add(time.Minute * 5)),
|
||||
Issuer: "rdpgw",
|
||||
}
|
||||
|
||||
enc, err := jose.NewEncrypter(
|
||||
jose.A128CBC_HS256,
|
||||
jose.Recipient{
|
||||
Algorithm: jose.DIRECT,
|
||||
Key: UserEncryptionKey,
|
||||
},
|
||||
(&jose.EncrypterOptions{Compression: jose.DEFLATE}).WithContentType("JWT"),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Cannot encrypt user token due to %s", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
// this makes the token bigger and we deal with a limited space of 511 characters
|
||||
if len(UserSigningKey) > 0 {
|
||||
sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS256, Key: UserSigningKey}, nil)
|
||||
token, err := jwt.SignedAndEncrypted(sig, enc).Claims(claims).Serialize()
|
||||
if len(token) > 511 {
|
||||
log.Printf("WARNING: token too long: len %d > 511", len(token))
|
||||
}
|
||||
return token, err
|
||||
}
|
||||
|
||||
// no signature
|
||||
token, err := jwt.Encrypted(enc).Claims(claims).Serialize()
|
||||
return token, err
|
||||
}
|
||||
|
||||
func UserInfo(ctx context.Context, token string) (jwt.Claims, error) {
|
||||
standard := jwt.Claims{}
|
||||
if len(UserEncryptionKey) > 0 && len(UserSigningKey) > 0 {
|
||||
enc, err := jwt.ParseSignedAndEncrypted(
|
||||
token,
|
||||
[]jose.KeyAlgorithm{jose.DIRECT},
|
||||
[]jose.ContentEncryption{jose.A128CBC_HS256},
|
||||
[]jose.SignatureAlgorithm{jose.HS256},
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Cannot get token %s", err)
|
||||
return standard, errors.New("cannot get token")
|
||||
}
|
||||
token, err := enc.Decrypt(UserEncryptionKey)
|
||||
if err != nil {
|
||||
log.Printf("Cannot decrypt token %s", err)
|
||||
return standard, errors.New("cannot decrypt token")
|
||||
}
|
||||
if err = token.Claims(UserSigningKey, &standard); err != nil {
|
||||
log.Printf("cannot verify signature %s", err)
|
||||
return standard, errors.New("cannot verify signature")
|
||||
}
|
||||
} else if len(UserSigningKey) == 0 {
|
||||
token, err := jwt.ParseEncrypted(token, []jose.KeyAlgorithm{jose.DIRECT}, []jose.ContentEncryption{jose.A128CBC_HS256})
|
||||
if err != nil {
|
||||
log.Printf("Cannot get token %s", err)
|
||||
return standard, errors.New("cannot get token")
|
||||
}
|
||||
err = token.Claims(UserEncryptionKey, &standard)
|
||||
if err != nil {
|
||||
log.Printf("Cannot decrypt token %s", err)
|
||||
return standard, errors.New("cannot decrypt token")
|
||||
}
|
||||
}
|
||||
|
||||
// go-jose doesnt verify the expiry
|
||||
err := standard.Validate(jwt.Expected{
|
||||
Issuer: "rdpgw",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("token validation failed due to %s", err)
|
||||
return standard, fmt.Errorf("token validation failed due to %s", err)
|
||||
}
|
||||
|
||||
return standard, nil
|
||||
}
|
||||
|
||||
func QueryInfo(ctx context.Context, tokenString string, issuer string) (string, error) {
|
||||
standard := jwt.Claims{}
|
||||
token, err := jwt.ParseSigned(tokenString, []jose.SignatureAlgorithm{jose.HS256})
|
||||
if err != nil {
|
||||
log.Printf("Cannot get token %s", err)
|
||||
return "", errors.New("cannot get token")
|
||||
}
|
||||
err = token.Claims(QuerySigningKey, &standard)
|
||||
if err = token.Claims(QuerySigningKey, &standard); err != nil {
|
||||
log.Printf("cannot verify signature %s", err)
|
||||
return "", errors.New("cannot verify signature")
|
||||
}
|
||||
|
||||
// go-jose doesnt verify the expiry
|
||||
err = standard.Validate(jwt.Expected{
|
||||
Issuer: issuer,
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("token validation failed due to %s", err)
|
||||
return "", fmt.Errorf("token validation failed due to %s", err)
|
||||
}
|
||||
|
||||
return standard.Subject, nil
|
||||
}
|
||||
|
||||
// GenerateQueryToken this is a helper function for testing
|
||||
func GenerateQueryToken(ctx context.Context, query string, issuer string) (string, error) {
|
||||
if len(QuerySigningKey) < 32 {
|
||||
return "", errors.New("query token encryption key not long enough or not specified")
|
||||
}
|
||||
|
||||
claims := jwt.Claims{
|
||||
Subject: query,
|
||||
Expiry: jwt.NewNumericDate(time.Now().Add(time.Minute * 5)),
|
||||
Issuer: issuer,
|
||||
}
|
||||
|
||||
sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS256, Key: QuerySigningKey},
|
||||
(&jose.SignerOptions{}).WithBase64(true))
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Cannot encrypt user token due to %s", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
token, err := jwt.Signed(sig).Claims(claims).Serialize()
|
||||
return token, err
|
||||
}
|
||||
|
||||
func getTunnel(ctx context.Context) *protocol.Tunnel {
|
||||
s, ok := ctx.Value(protocol.CtxTunnel).(*protocol.Tunnel)
|
||||
if !ok {
|
||||
log.Printf("cannot get session info from context")
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
76
cmd/rdpgw/security/jwt_test.go
Normal file
76
cmd/rdpgw/security/jwt_test.go
Normal file
@ -0,0 +1,76 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/identity"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateUserToken(t *testing.T) {
|
||||
cases := []struct {
|
||||
SigningKey []byte
|
||||
EncryptionKey []byte
|
||||
name string
|
||||
username string
|
||||
}{
|
||||
{
|
||||
SigningKey: []byte("5aa3a1568fe8421cd7e127d5ace28d2d"),
|
||||
EncryptionKey: []byte("d3ecd7e565e56e37e2f2e95b584d8c0c"),
|
||||
name: "sign_and_encrypt",
|
||||
username: "test_sign_and_encrypt",
|
||||
},
|
||||
{
|
||||
SigningKey: nil,
|
||||
EncryptionKey: []byte("d3ecd7e565e56e37e2f2e95b584d8c0c"),
|
||||
name: "encrypt_only",
|
||||
username: "test_encrypt_only",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
SigningKey = tc.SigningKey
|
||||
UserEncryptionKey = tc.EncryptionKey
|
||||
token, err := GenerateUserToken(context.Background(), tc.username)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateUserToken failed: %s", err)
|
||||
}
|
||||
claims, err := UserInfo(context.Background(), token)
|
||||
if err != nil {
|
||||
t.Fatalf("UserInfo failed: %s", err)
|
||||
}
|
||||
if claims.Subject != tc.username {
|
||||
t.Fatalf("Expected %s, got %s", tc.username, claims.Subject)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestPAACookie(t *testing.T) {
|
||||
SigningKey = []byte("5aa3a1568fe8421cd7e127d5ace28d2d")
|
||||
EncryptionKey = []byte("d3ecd7e565e56e37e2f2e95b584d8c0c")
|
||||
|
||||
username := "test_paa_cookie"
|
||||
attr_client_ip := "127.0.0.1"
|
||||
attr_access_token := "aabbcc"
|
||||
|
||||
id := identity.NewUser()
|
||||
id.SetUserName(username)
|
||||
id.SetAttribute(identity.AttrClientIp, attr_client_ip)
|
||||
id.SetAttribute(identity.AttrAccessToken, attr_access_token)
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = context.WithValue(ctx, identity.CTXKey, id)
|
||||
|
||||
_, err := GeneratePAAToken(ctx, "test_paa_cookie", "host.does.not.exist")
|
||||
if err != nil {
|
||||
t.Fatalf("GeneratePAAToken failed: %s", err)
|
||||
}
|
||||
/*ok, err := CheckPAACookie(ctx, token)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckPAACookie failed: %s", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("CheckPAACookie failed")
|
||||
}*/
|
||||
}
|
||||
39
cmd/rdpgw/security/string.go
Normal file
39
cmd/rdpgw/security/string.go
Normal file
@ -0,0 +1,39 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
// GenerateRandomBytes returns securely generated random bytes.
|
||||
// It will return an error if the system's secure random
|
||||
// number generator fails to function correctly, in which
|
||||
// case the caller should not continue.
|
||||
func GenerateRandomBytes(n int) ([]byte, error) {
|
||||
b := make([]byte, n)
|
||||
_, err := rand.Read(b)
|
||||
// Note that err == nil only if we read len(b) bytes.
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// GenerateRandomString returns a securely generated random string.
|
||||
// It will return an error if the system's secure random
|
||||
// number generator fails to function correctly, in which
|
||||
// case the caller should not continue.
|
||||
func GenerateRandomString(n int) (string, error) {
|
||||
const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-"
|
||||
ret := make([]byte, n)
|
||||
for i := 0; i < n; i++ {
|
||||
num, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ret[i] = letters[num.Int64()]
|
||||
}
|
||||
|
||||
return string(ret), nil
|
||||
}
|
||||
@ -2,9 +2,9 @@ package transport
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
@ -12,14 +12,14 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
crlf = "\r\n"
|
||||
crlf = "\r\n"
|
||||
HttpOK = "HTTP/1.1 200 OK\r\n"
|
||||
)
|
||||
|
||||
type LegacyPKT struct {
|
||||
Conn net.Conn
|
||||
Conn net.Conn
|
||||
ChunkedReader io.Reader
|
||||
Writer *bufio.Writer
|
||||
Writer *bufio.Writer
|
||||
}
|
||||
|
||||
func NewLegacy(w http.ResponseWriter) (*LegacyPKT, error) {
|
||||
@ -27,9 +27,9 @@ func NewLegacy(w http.ResponseWriter) (*LegacyPKT, error) {
|
||||
if ok {
|
||||
conn, rw, err := hj.Hijack()
|
||||
l := &LegacyPKT{
|
||||
Conn: conn,
|
||||
Conn: conn,
|
||||
ChunkedReader: httputil.NewChunkedReader(rw.Reader),
|
||||
Writer: rw.Writer,
|
||||
Writer: rw.Writer,
|
||||
}
|
||||
return l, err
|
||||
}
|
||||
@ -37,7 +37,7 @@ func NewLegacy(w http.ResponseWriter) (*LegacyPKT, error) {
|
||||
return nil, errors.New("cannot hijack connection")
|
||||
}
|
||||
|
||||
func (t *LegacyPKT) ReadPacket() (n int, p []byte, err error){
|
||||
func (t *LegacyPKT) ReadPacket() (n int, p []byte, err error) {
|
||||
buf := make([]byte, 4096) // bufio.defaultBufSize
|
||||
n, err = t.ChunkedReader.Read(buf)
|
||||
p = make([]byte, n)
|
||||
82
cmd/rdpgw/web/basic.go
Normal file
82
cmd/rdpgw/web/basic.go
Normal file
@ -0,0 +1,82 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/identity"
|
||||
"github.com/bolkedebruin/rdpgw/shared/auth"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
protocolGrpc = "unix"
|
||||
)
|
||||
|
||||
type BasicAuthHandler struct {
|
||||
SocketAddress string
|
||||
Timeout int
|
||||
}
|
||||
|
||||
func (h *BasicAuthHandler) BasicAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
username, password, ok := r.BasicAuth()
|
||||
if ok {
|
||||
authenticated := h.authenticate(w, r, username, password)
|
||||
|
||||
if !authenticated {
|
||||
log.Printf("User %s is not authenticated for this service", username)
|
||||
} else {
|
||||
log.Printf("User %s authenticated", username)
|
||||
id := identity.FromRequestCtx(r)
|
||||
id.SetUserName(username)
|
||||
id.SetAuthenticated(true)
|
||||
id.SetAuthTime(time.Now())
|
||||
next.ServeHTTP(w, identity.AddToRequestCtx(id, r))
|
||||
return
|
||||
}
|
||||
}
|
||||
// If the Authentication header is not present, is invalid, or the
|
||||
// username or password is wrong, then set a WWW-Authenticate
|
||||
// header to inform the client that we expect them to use basic
|
||||
// authentication and send a 401 Unauthorized response.
|
||||
w.Header().Add("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *BasicAuthHandler) authenticate(w http.ResponseWriter, r *http.Request, username string, password string) (authenticated bool) {
|
||||
if h.SocketAddress == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
conn, err := grpc.Dial(h.SocketAddress, grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
|
||||
return net.Dial(protocolGrpc, addr)
|
||||
}))
|
||||
if err != nil {
|
||||
log.Printf("Cannot reach authentication provider: %s", err)
|
||||
http.Error(w, "Server error", http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
c := auth.NewAuthenticateClient(conn)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*time.Duration(h.Timeout))
|
||||
defer cancel()
|
||||
|
||||
req := &auth.UserPass{Username: username, Password: password}
|
||||
res, err := c.Authenticate(ctx, req)
|
||||
if err != nil {
|
||||
log.Printf("Error talking to authentication provider: %s", err)
|
||||
http.Error(w, "Server error", http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
|
||||
return res.Authenticated
|
||||
}
|
||||
68
cmd/rdpgw/web/context.go
Normal file
68
cmd/rdpgw/web/context.go
Normal file
@ -0,0 +1,68 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/identity"
|
||||
"github.com/jcmturner/goidentity/v6"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func EnrichContext(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := GetSessionIdentity(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if id == nil {
|
||||
id = identity.NewUser()
|
||||
if err := SaveSessionIdentity(r, w, id); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Identity SessionId: %s, UserName: %s: Authenticated: %t",
|
||||
id.SessionId(), id.UserName(), id.Authenticated())
|
||||
|
||||
h := r.Header.Get("X-Forwarded-For")
|
||||
if h != "" {
|
||||
var proxies []string
|
||||
ips := strings.Split(h, ",")
|
||||
for i := range ips {
|
||||
ips[i] = strings.TrimSpace(ips[i])
|
||||
}
|
||||
clientIp := ips[0]
|
||||
if len(ips) > 1 {
|
||||
proxies = ips[1:]
|
||||
}
|
||||
id.SetAttribute(identity.AttrClientIp, clientIp)
|
||||
id.SetAttribute(identity.AttrProxies, proxies)
|
||||
}
|
||||
|
||||
id.SetAttribute(identity.AttrRemoteAddr, r.RemoteAddr)
|
||||
if h == "" {
|
||||
clientIp, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
id.SetAttribute(identity.AttrClientIp, clientIp)
|
||||
}
|
||||
next.ServeHTTP(w, identity.AddToRequestCtx(id, r))
|
||||
})
|
||||
}
|
||||
|
||||
func TransposeSPNEGOContext(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gid := goidentity.FromHTTPRequestContext(r)
|
||||
if gid != nil {
|
||||
id := identity.FromRequestCtx(r)
|
||||
id.SetUserName(gid.UserName())
|
||||
id.SetAuthenticated(gid.Authenticated())
|
||||
id.SetDomain(gid.Domain())
|
||||
id.SetAuthTime(gid.AuthTime())
|
||||
r = identity.AddToRequestCtx(id, r)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
29
cmd/rdpgw/web/mux.go
Normal file
29
cmd/rdpgw/web/mux.go
Normal file
@ -0,0 +1,29 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type AuthMux struct {
|
||||
headers []string
|
||||
}
|
||||
|
||||
func NewAuthMux() *AuthMux {
|
||||
return &AuthMux{}
|
||||
}
|
||||
|
||||
func (a *AuthMux) Register(s string) {
|
||||
a.headers = append(a.headers, s)
|
||||
}
|
||||
|
||||
func (a *AuthMux) SetAuthenticate(w http.ResponseWriter, r *http.Request) {
|
||||
for _, s := range a.headers {
|
||||
w.Header().Add("WWW-Authenticate", s)
|
||||
}
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
func NoAuthz(r *http.Request, rm *mux.RouteMatch) bool {
|
||||
return r.Header.Get("Authorization") == ""
|
||||
}
|
||||
120
cmd/rdpgw/web/ntlm.go
Normal file
120
cmd/rdpgw/web/ntlm.go
Normal file
@ -0,0 +1,120 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/identity"
|
||||
"github.com/bolkedebruin/rdpgw/shared/auth"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ntlmAuthMode uint32
|
||||
const (
|
||||
authNone ntlmAuthMode = iota
|
||||
authNTLM
|
||||
authNegotiate
|
||||
)
|
||||
|
||||
type NTLMAuthHandler struct {
|
||||
SocketAddress string
|
||||
Timeout int
|
||||
}
|
||||
|
||||
func (h *NTLMAuthHandler) NTLMAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
authPayload, authMode, err := h.getAuthPayload(r)
|
||||
if err != nil {
|
||||
log.Printf("Failed parsing auth header: %s", err)
|
||||
h.requestAuthenticate(w)
|
||||
return
|
||||
}
|
||||
|
||||
authenticated, username := h.authenticate(w, r, authPayload, authMode)
|
||||
|
||||
if authenticated {
|
||||
log.Printf("NTLM: User %s authenticated", username)
|
||||
id := identity.FromRequestCtx(r)
|
||||
id.SetUserName(username)
|
||||
id.SetAuthenticated(true)
|
||||
id.SetAuthTime(time.Now())
|
||||
next.ServeHTTP(w, identity.AddToRequestCtx(id, r))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *NTLMAuthHandler) getAuthPayload (r *http.Request) (payload string, authMode ntlmAuthMode, err error) {
|
||||
authorisationEncoded := r.Header.Get("Authorization")
|
||||
if authorisationEncoded[0:5] == "NTLM " {
|
||||
return authorisationEncoded[5:], authNTLM, nil
|
||||
}
|
||||
if authorisationEncoded[0:10] == "Negotiate " {
|
||||
return authorisationEncoded[10:], authNegotiate, nil
|
||||
}
|
||||
return "", authNone, errors.New("Invalid NTLM Authorisation header")
|
||||
}
|
||||
|
||||
func (h *NTLMAuthHandler) requestAuthenticate (w http.ResponseWriter) {
|
||||
w.Header().Add("WWW-Authenticate", `NTLM`)
|
||||
w.Header().Add("WWW-Authenticate", `Negotiate`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
func (h *NTLMAuthHandler) getAuthPrefix (authMode ntlmAuthMode) (prefix string) {
|
||||
if authMode == authNTLM {
|
||||
return "NTLM "
|
||||
}
|
||||
if authMode == authNegotiate {
|
||||
return "Negotiate "
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *NTLMAuthHandler) authenticate(w http.ResponseWriter, r *http.Request, authorisationEncoded string, authMode ntlmAuthMode) (authenticated bool, username string) {
|
||||
if h.SocketAddress == "" {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
conn, err := grpc.Dial(h.SocketAddress, grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
|
||||
return net.Dial(protocolGrpc, addr)
|
||||
}))
|
||||
if err != nil {
|
||||
log.Printf("Cannot reach authentication provider: %s", err)
|
||||
http.Error(w, "Server error", http.StatusInternalServerError)
|
||||
return false, ""
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
c := auth.NewAuthenticateClient(conn)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*time.Duration(h.Timeout))
|
||||
defer cancel()
|
||||
|
||||
req := &auth.NtlmRequest{Session: r.RemoteAddr, NtlmMessage: authorisationEncoded}
|
||||
res, err := c.NTLM(ctx, req)
|
||||
if err != nil {
|
||||
log.Printf("Error talking to authentication provider: %s", err)
|
||||
http.Error(w, "Server error", http.StatusInternalServerError)
|
||||
return false, ""
|
||||
}
|
||||
|
||||
if res.NtlmMessage != "" {
|
||||
log.Printf("Sending NTLM challenge")
|
||||
w.Header().Add("WWW-Authenticate", h.getAuthPrefix(authMode)+res.NtlmMessage)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return false, ""
|
||||
}
|
||||
|
||||
if !res.Authenticated {
|
||||
h.requestAuthenticate(w)
|
||||
return false, ""
|
||||
}
|
||||
|
||||
return res.Authenticated, res.Username
|
||||
}
|
||||
133
cmd/rdpgw/web/oidc.go
Normal file
133
cmd/rdpgw/web/oidc.go
Normal file
@ -0,0 +1,133 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/identity"
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/patrickmn/go-cache"
|
||||
"golang.org/x/oauth2"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
CacheExpiration = time.Minute * 2
|
||||
CleanupInterval = time.Minute * 5
|
||||
)
|
||||
|
||||
type OIDC struct {
|
||||
oAuth2Config *oauth2.Config
|
||||
oidcTokenVerifier *oidc.IDTokenVerifier
|
||||
stateStore *cache.Cache
|
||||
}
|
||||
|
||||
type OIDCConfig struct {
|
||||
OAuth2Config *oauth2.Config
|
||||
OIDCTokenVerifier *oidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
func (c *OIDCConfig) New() *OIDC {
|
||||
return &OIDC{
|
||||
oAuth2Config: c.OAuth2Config,
|
||||
oidcTokenVerifier: c.OIDCTokenVerifier,
|
||||
stateStore: cache.New(CacheExpiration, CleanupInterval),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *OIDC) HandleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
state := r.URL.Query().Get("state")
|
||||
s, found := h.stateStore.Get(state)
|
||||
if !found {
|
||||
http.Error(w, "unknown state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
url := s.(string)
|
||||
|
||||
ctx := r.Context()
|
||||
oauth2Token, err := h.oAuth2Config.Exchange(ctx, r.URL.Query().Get("code"))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to exchange token: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
http.Error(w, "No id_token field in oauth2 token.", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
idToken, err := h.oidcTokenVerifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to verify ID Token: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
resp := struct {
|
||||
OAuth2Token *oauth2.Token
|
||||
IDTokenClaims *json.RawMessage // ID Token payload is just JSON.
|
||||
}{oauth2Token, new(json.RawMessage)}
|
||||
|
||||
if err := idToken.Claims(&resp.IDTokenClaims); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(*resp.IDTokenClaims, &data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
id := identity.FromRequestCtx(r)
|
||||
|
||||
userName := findUsernameInClaims(data)
|
||||
if userName == "" {
|
||||
http.Error(w, "no oidc claim for username found", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
id.SetUserName(userName)
|
||||
id.SetAuthenticated(true)
|
||||
id.SetAuthTime(time.Now())
|
||||
id.SetAttribute(identity.AttrAccessToken, oauth2Token.AccessToken)
|
||||
|
||||
if err = SaveSessionIdentity(r, w, id); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
http.Redirect(w, r, url, http.StatusFound)
|
||||
}
|
||||
|
||||
func findUsernameInClaims(data map[string]interface{}) string {
|
||||
candidates := []string{"preferred_username", "unique_name", "upn", "username"}
|
||||
for _, claim := range candidates {
|
||||
userName, found := data[claim].(string)
|
||||
if found {
|
||||
return userName
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *OIDC) Authenticated(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := identity.FromRequestCtx(r)
|
||||
|
||||
if !id.Authenticated() {
|
||||
seed := make([]byte, 16)
|
||||
_, err := rand.Read(seed)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
state := hex.EncodeToString(seed)
|
||||
h.stateStore.Set(state, r.RequestURI, cache.DefaultExpiration)
|
||||
http.Redirect(w, r, h.oAuth2Config.AuthCodeURL(state), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// replace the identity with the one from the sessions
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
49
cmd/rdpgw/web/oidc_test.go
Normal file
49
cmd/rdpgw/web/oidc_test.go
Normal file
@ -0,0 +1,49 @@
|
||||
package web
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFindUserNameInClaims(t *testing.T) {
|
||||
cases := []struct {
|
||||
data map[string]interface{}
|
||||
ret string
|
||||
name string
|
||||
}{
|
||||
{
|
||||
data: map[string]interface{}{
|
||||
"preferred_username": "exists",
|
||||
},
|
||||
ret: "exists",
|
||||
name: "preferred_username",
|
||||
},
|
||||
{
|
||||
data: map[string]interface{}{
|
||||
"upn": "exists",
|
||||
},
|
||||
ret: "exists",
|
||||
name: "upn",
|
||||
},
|
||||
{
|
||||
data: map[string]interface{}{
|
||||
"unique_name": "exists",
|
||||
},
|
||||
ret: "exists",
|
||||
name: "unique_name",
|
||||
},
|
||||
{
|
||||
data: map[string]interface{}{
|
||||
"fail": "exists",
|
||||
},
|
||||
ret: "",
|
||||
name: "fail",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := findUsernameInClaims(tc.data)
|
||||
if s != tc.ret {
|
||||
t.Fatalf("expected return: %v, got: %v", tc.ret, s)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
85
cmd/rdpgw/web/session.go
Normal file
85
cmd/rdpgw/web/session.go
Normal file
@ -0,0 +1,85 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/identity"
|
||||
"github.com/gorilla/sessions"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
const (
|
||||
rdpGwSession = "RDPGWSESSION"
|
||||
MaxAge = 120
|
||||
identityKey = "RDPGWID"
|
||||
maxSessionLength = 8192
|
||||
)
|
||||
|
||||
var sessionStore sessions.Store
|
||||
|
||||
func InitStore(sessionKey []byte, encryptionKey []byte, storeType string, maxLength int) {
|
||||
if len(sessionKey) < 32 {
|
||||
log.Fatal("Session key too small")
|
||||
}
|
||||
if len(encryptionKey) < 32 {
|
||||
log.Fatal("Session key too small")
|
||||
}
|
||||
|
||||
if storeType == "file" {
|
||||
log.Println("Filesystem is used as session storage")
|
||||
fs := sessions.NewFilesystemStore(os.TempDir(), sessionKey, encryptionKey)
|
||||
|
||||
// set max length
|
||||
if maxLength == 0 {
|
||||
maxLength = maxSessionLength
|
||||
}
|
||||
log.Printf("Setting maximum session storage to %d bytes", maxLength)
|
||||
fs.MaxLength(maxLength)
|
||||
|
||||
sessionStore = fs
|
||||
} else {
|
||||
log.Println("Cookies are used as session storage")
|
||||
sessionStore = sessions.NewCookieStore(sessionKey, encryptionKey)
|
||||
}
|
||||
}
|
||||
|
||||
func GetSession(r *http.Request) (*sessions.Session, error) {
|
||||
session, err := sessionStore.Get(r, rdpGwSession)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func GetSessionIdentity(r *http.Request) (identity.Identity, error) {
|
||||
s, err := GetSession(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
idData := s.Values[identityKey]
|
||||
if idData == nil {
|
||||
return nil, nil
|
||||
|
||||
}
|
||||
id := identity.NewUser()
|
||||
id.Unmarshal(idData.([]byte))
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func SaveSessionIdentity(r *http.Request, w http.ResponseWriter, id identity.Identity) error {
|
||||
session, err := GetSession(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
session.Options.MaxAge = MaxAge
|
||||
|
||||
idData, err := id.Marshal()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
session.Values[identityKey] = idData
|
||||
|
||||
return sessionStore.Save(r, w, session)
|
||||
|
||||
}
|
||||
@ -1,15 +1,15 @@
|
||||
package api
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/bolkedebruin/rdpgw/security"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/security"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (c *Config) TokenInfo(w http.ResponseWriter, r *http.Request) {
|
||||
func TokenInfo(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Invalid request", http.StatusMethodNotAllowed)
|
||||
return
|
||||
@ -37,4 +37,4 @@ func (c *Config) TokenInfo(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "cannot encode json", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
228
cmd/rdpgw/web/web.go
Normal file
228
cmd/rdpgw/web/web.go
Normal file
@ -0,0 +1,228 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/identity"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/rdp"
|
||||
"hash/maphash"
|
||||
"log"
|
||||
rnd "math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TokenGeneratorFunc func(context.Context, string, string) (string, error)
|
||||
type UserTokenGeneratorFunc func(context.Context, string) (string, error)
|
||||
type QueryInfoFunc func(context.Context, string, string) (string, error)
|
||||
|
||||
type Config struct {
|
||||
PAATokenGenerator TokenGeneratorFunc
|
||||
UserTokenGenerator UserTokenGeneratorFunc
|
||||
QueryInfo QueryInfoFunc
|
||||
QueryTokenIssuer string
|
||||
EnableUserToken bool
|
||||
Hosts []string
|
||||
HostSelection string
|
||||
GatewayAddress *url.URL
|
||||
RdpOpts RdpOpts
|
||||
TemplateFile string
|
||||
}
|
||||
|
||||
type RdpOpts struct {
|
||||
UsernameTemplate string
|
||||
SplitUserDomain bool
|
||||
NoUsername bool
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
paaTokenGenerator TokenGeneratorFunc
|
||||
enableUserToken bool
|
||||
userTokenGenerator UserTokenGeneratorFunc
|
||||
queryInfo QueryInfoFunc
|
||||
queryTokenIssuer string
|
||||
gatewayAddress *url.URL
|
||||
hosts []string
|
||||
hostSelection string
|
||||
rdpOpts RdpOpts
|
||||
rdpDefaults string
|
||||
}
|
||||
|
||||
func (c *Config) NewHandler() *Handler {
|
||||
if len(c.Hosts) < 1 {
|
||||
log.Fatal("Not enough hosts to connect to specified")
|
||||
}
|
||||
|
||||
return &Handler{
|
||||
paaTokenGenerator: c.PAATokenGenerator,
|
||||
enableUserToken: c.EnableUserToken,
|
||||
userTokenGenerator: c.UserTokenGenerator,
|
||||
queryInfo: c.QueryInfo,
|
||||
queryTokenIssuer: c.QueryTokenIssuer,
|
||||
gatewayAddress: c.GatewayAddress,
|
||||
hosts: c.Hosts,
|
||||
hostSelection: c.HostSelection,
|
||||
rdpOpts: c.RdpOpts,
|
||||
rdpDefaults: c.TemplateFile,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) selectRandomHost() string {
|
||||
r := rnd.New(rnd.NewSource(int64(new(maphash.Hash).Sum64())))
|
||||
host := h.hosts[r.Intn(len(h.hosts))]
|
||||
return host
|
||||
}
|
||||
|
||||
func (h *Handler) getHost(ctx context.Context, u *url.URL) (string, error) {
|
||||
switch h.hostSelection {
|
||||
case "roundrobin":
|
||||
return h.selectRandomHost(), nil
|
||||
case "signed":
|
||||
hosts, ok := u.Query()["host"]
|
||||
if !ok {
|
||||
return "", errors.New("invalid query parameter")
|
||||
}
|
||||
host, err := h.queryInfo(ctx, hosts[0], h.queryTokenIssuer)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
found := false
|
||||
for _, check := range h.hosts {
|
||||
if check == host {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
log.Printf("Invalid host %s specified in token", hosts[0])
|
||||
return "", errors.New("invalid host specified in query token")
|
||||
}
|
||||
return host, nil
|
||||
case "unsigned":
|
||||
hosts, ok := u.Query()["host"]
|
||||
if !ok {
|
||||
return "", errors.New("invalid query parameter")
|
||||
}
|
||||
for _, check := range h.hosts {
|
||||
if check == hosts[0] {
|
||||
return hosts[0], nil
|
||||
}
|
||||
}
|
||||
// not found
|
||||
log.Printf("Invalid host %s specified in client request", hosts[0])
|
||||
return "", errors.New("invalid host specified in query parameter")
|
||||
case "any":
|
||||
hosts, ok := u.Query()["host"]
|
||||
if !ok {
|
||||
return "", errors.New("invalid query parameter")
|
||||
}
|
||||
return hosts[0], nil
|
||||
default:
|
||||
return h.selectRandomHost(), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) HandleDownload(w http.ResponseWriter, r *http.Request) {
|
||||
id := identity.FromRequestCtx(r)
|
||||
ctx := r.Context()
|
||||
|
||||
opts := h.rdpOpts
|
||||
|
||||
if !id.Authenticated() {
|
||||
log.Printf("unauthenticated user %s", id.UserName())
|
||||
http.Error(w, errors.New("cannot find session or user").Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// determine host to connect to
|
||||
host, err := h.getHost(ctx, r.URL)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
host = strings.Replace(host, "{{ preferred_username }}", id.UserName(), 1)
|
||||
|
||||
// split the username into user and domain
|
||||
var user = id.UserName()
|
||||
var domain = ""
|
||||
if opts.SplitUserDomain {
|
||||
creds := strings.SplitN(id.UserName(), "@", 2)
|
||||
user = creds[0]
|
||||
if len(creds) > 1 {
|
||||
domain = creds[1]
|
||||
}
|
||||
}
|
||||
|
||||
render := user
|
||||
if opts.UsernameTemplate != "" {
|
||||
render = fmt.Sprintf(h.rdpOpts.UsernameTemplate)
|
||||
render = strings.Replace(render, "{{ username }}", user, 1)
|
||||
if h.rdpOpts.UsernameTemplate == render {
|
||||
log.Printf("Invalid username template. %s == %s", h.rdpOpts.UsernameTemplate, user)
|
||||
http.Error(w, errors.New("invalid server configuration").Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
token, err := h.paaTokenGenerator(ctx, user, host)
|
||||
if err != nil {
|
||||
log.Printf("Cannot generate PAA token for user %s due to %s", user, err)
|
||||
http.Error(w, errors.New("unable to generate gateway credentials").Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if h.enableUserToken {
|
||||
userToken, err := h.userTokenGenerator(ctx, user)
|
||||
if err != nil {
|
||||
log.Printf("Cannot generate token for user %s due to %s", user, err)
|
||||
http.Error(w, errors.New("unable to generate gateway credentials").Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
render = strings.Replace(render, "{{ token }}", userToken, 1)
|
||||
}
|
||||
|
||||
// authenticated
|
||||
seed := make([]byte, 16)
|
||||
_, err = rand.Read(seed)
|
||||
if err != nil {
|
||||
log.Printf("Cannot generate random seed due to %s", err)
|
||||
http.Error(w, errors.New("unable to generate random sequence").Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
fn := hex.EncodeToString(seed) + ".rdp"
|
||||
|
||||
w.Header().Set("Content-Disposition", "attachment; filename="+fn)
|
||||
w.Header().Set("Content-Type", "application/x-rdp")
|
||||
|
||||
var d *rdp.Builder
|
||||
if h.rdpDefaults == "" {
|
||||
d = rdp.NewBuilder()
|
||||
} else {
|
||||
d, err = rdp.NewBuilderFromFile(h.rdpDefaults)
|
||||
if err != nil {
|
||||
log.Printf("Cannot load RDP template file %s due to %s", h.rdpDefaults, err)
|
||||
http.Error(w, errors.New("unable to load RDP template").Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !h.rdpOpts.NoUsername {
|
||||
d.Settings.Username = render
|
||||
if domain != "" {
|
||||
d.Settings.Domain = domain
|
||||
}
|
||||
}
|
||||
d.Settings.FullAddress = host
|
||||
d.Settings.GatewayHostname = h.gatewayAddress.Host
|
||||
d.Settings.GatewayCredentialsSource = rdp.SourceCookie
|
||||
d.Settings.GatewayAccessToken = token
|
||||
d.Settings.GatewayCredentialMethod = 1
|
||||
d.Settings.GatewayUsageMethod = 1
|
||||
|
||||
http.ServeContent(w, r, fn, time.Now(), strings.NewReader(d.String()))
|
||||
}
|
||||
235
cmd/rdpgw/web/web_test.go
Normal file
235
cmd/rdpgw/web/web_test.go
Normal file
@ -0,0 +1,235 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/identity"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/rdp"
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/security"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
testuser = "test_user"
|
||||
gateway = "https://my.gateway.com:993"
|
||||
)
|
||||
|
||||
var (
|
||||
hosts = []string{"10.0.0.1:3389", "10.1.1.1:3000", "32.32.11.1", "remote.host.com"}
|
||||
key = []byte("thisisasessionkeyreplacethisjetzt")
|
||||
)
|
||||
|
||||
func contains(needle string, haystack []string) bool {
|
||||
for _, val := range haystack {
|
||||
if val == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestGetHost(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := Config{
|
||||
HostSelection: "roundrobin",
|
||||
Hosts: hosts,
|
||||
}
|
||||
h := c.NewHandler()
|
||||
|
||||
u := &url.URL{
|
||||
Host: "example.com",
|
||||
}
|
||||
vals := u.Query()
|
||||
|
||||
host, err := h.getHost(ctx, u)
|
||||
if err != nil {
|
||||
t.Fatalf("#{err}")
|
||||
}
|
||||
if !contains(host, hosts) {
|
||||
t.Fatalf("host %s is not in hosts list", host)
|
||||
}
|
||||
|
||||
// check unsigned
|
||||
c.HostSelection = "unsigned"
|
||||
vals.Set("host", "in.valid.host")
|
||||
u.RawQuery = vals.Encode()
|
||||
h = c.NewHandler()
|
||||
host, err = h.getHost(ctx, u)
|
||||
if err == nil {
|
||||
t.Fatalf("Accepted host %s is not in hosts list", host)
|
||||
}
|
||||
|
||||
vals.Set("host", hosts[0])
|
||||
u.RawQuery = vals.Encode()
|
||||
h = c.NewHandler()
|
||||
host, err = h.getHost(ctx, u)
|
||||
if err != nil {
|
||||
t.Fatalf("Not accepted host %s is in hosts list (err: %s)", hosts[0], err)
|
||||
}
|
||||
if host != hosts[0] {
|
||||
t.Fatalf("host %s is not equal to input %s", host, hosts[0])
|
||||
}
|
||||
|
||||
// check any
|
||||
c.HostSelection = "any"
|
||||
test := "bla.bla.com"
|
||||
vals.Set("host", test)
|
||||
u.RawQuery = vals.Encode()
|
||||
h = c.NewHandler()
|
||||
host, err = h.getHost(ctx, u)
|
||||
if err != nil {
|
||||
t.Fatalf("%s is not accepted", host)
|
||||
}
|
||||
if test != host {
|
||||
t.Fatalf("Returned host %s is not equal to input host %s", host, test)
|
||||
}
|
||||
|
||||
// check signed
|
||||
c.HostSelection = "signed"
|
||||
c.QueryInfo = security.QueryInfo
|
||||
issuer := "rdpgwtest"
|
||||
security.QuerySigningKey = key
|
||||
queryToken, err := security.GenerateQueryToken(ctx, hosts[0], issuer)
|
||||
if err != nil {
|
||||
t.Fatalf("cannot generate token")
|
||||
}
|
||||
vals.Set("host", queryToken)
|
||||
u.RawQuery = vals.Encode()
|
||||
h = c.NewHandler()
|
||||
host, err = h.getHost(ctx, u)
|
||||
if err != nil {
|
||||
t.Fatalf("Not accepted host %s is in hosts list (err: %s)", hosts[0], err)
|
||||
}
|
||||
if host != hosts[0] {
|
||||
t.Fatalf("%s does not equal %s", host, hosts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_HandleDownload(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", "/connect", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
id := identity.NewUser()
|
||||
|
||||
id.SetUserName(testuser)
|
||||
id.SetAuthenticated(true)
|
||||
|
||||
req = identity.AddToRequestCtx(id, req)
|
||||
ctx := req.Context()
|
||||
|
||||
u, _ := url.Parse(gateway)
|
||||
c := Config{
|
||||
HostSelection: "roundrobin",
|
||||
Hosts: hosts,
|
||||
PAATokenGenerator: paaTokenMock,
|
||||
GatewayAddress: u,
|
||||
RdpOpts: RdpOpts{SplitUserDomain: true},
|
||||
}
|
||||
h := c.NewHandler()
|
||||
|
||||
hh := http.HandlerFunc(h.HandleDownload)
|
||||
hh.ServeHTTP(rr, req)
|
||||
|
||||
if status := rr.Code; status != http.StatusOK {
|
||||
t.Errorf("handler returned wrong status code: got %v want %v",
|
||||
status, http.StatusOK)
|
||||
}
|
||||
|
||||
if ctype := rr.Header().Get("Content-Type"); ctype != "application/x-rdp" {
|
||||
t.Errorf("content type header does not match: got %v want %v",
|
||||
ctype, "application/json")
|
||||
}
|
||||
|
||||
if cdisp := rr.Header().Get("Content-Disposition"); cdisp == "" {
|
||||
t.Errorf("content disposition is nil")
|
||||
}
|
||||
|
||||
data := rdpToMap(strings.Split(rr.Body.String(), rdp.CRLF))
|
||||
if data["username"] != testuser {
|
||||
t.Errorf("username key in rdp does not match: got %v want %v", data["username"], testuser)
|
||||
}
|
||||
|
||||
if data["gatewayhostname"] != u.Host {
|
||||
t.Errorf("gatewayhostname key in rdp does not match: got %v want %v", data["gatewayhostname"], u.Host)
|
||||
}
|
||||
|
||||
if token, _ := paaTokenMock(ctx, testuser, data["full address"]); token != data["gatewayaccesstoken"] {
|
||||
t.Errorf("gatewayaccesstoken key in rdp does not match username_full address: got %v want %v",
|
||||
data["gatewayaccesstoken"], token)
|
||||
}
|
||||
|
||||
if !contains(data["full address"], hosts) {
|
||||
t.Errorf("full address key in rdp is not in allowed hosts list: go %v want in %v",
|
||||
data["full address"], hosts)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestHandler_HandleDownloadWithRdpTemplate(t *testing.T) {
|
||||
f, err := os.CreateTemp("", "rdp")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
|
||||
err = os.WriteFile(f.Name(), []byte("domain:s:testdomain\r\n"), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", "/connect", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
id := identity.NewUser()
|
||||
|
||||
id.SetUserName(testuser)
|
||||
id.SetAuthenticated(true)
|
||||
|
||||
req = identity.AddToRequestCtx(id, req)
|
||||
|
||||
u, _ := url.Parse(gateway)
|
||||
c := Config{
|
||||
HostSelection: "roundrobin",
|
||||
Hosts: hosts,
|
||||
PAATokenGenerator: paaTokenMock,
|
||||
GatewayAddress: u,
|
||||
RdpOpts: RdpOpts{SplitUserDomain: true},
|
||||
TemplateFile: f.Name(),
|
||||
}
|
||||
h := c.NewHandler()
|
||||
|
||||
hh := http.HandlerFunc(h.HandleDownload)
|
||||
hh.ServeHTTP(rr, req)
|
||||
|
||||
data := rdpToMap(strings.Split(rr.Body.String(), rdp.CRLF))
|
||||
if data["domain"] != "testdomain" {
|
||||
t.Errorf("domain key in rdp does not match: got %v want %v", data["domain"], "testdomain")
|
||||
}
|
||||
}
|
||||
|
||||
func paaTokenMock(ctx context.Context, username string, host string) (string, error) {
|
||||
return username + "_" + host, nil
|
||||
}
|
||||
|
||||
func rdpToMap(rdp []string) map[string]string {
|
||||
ret := make(map[string]string)
|
||||
|
||||
for s := range rdp {
|
||||
d := strings.SplitN(rdp[s], ":", 3)
|
||||
if len(d) >= 2 {
|
||||
ret[d[0]] = d[2]
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
@ -1,60 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
ClientIPCtx = "ClientIP"
|
||||
ProxyAddressesCtx = "ProxyAddresses"
|
||||
RemoteAddressCtx = "RemoteAddress"
|
||||
)
|
||||
|
||||
func EnrichContext(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
h := r.Header.Get("X-Forwarded-For")
|
||||
if h != "" {
|
||||
var proxies []string
|
||||
ips := strings.Split(h, ",")
|
||||
for i := range ips {
|
||||
ips[i] = strings.TrimSpace(ips[i])
|
||||
}
|
||||
clientIp := ips[0]
|
||||
if len(ips) > 1 {
|
||||
proxies = ips[1:]
|
||||
}
|
||||
ctx = context.WithValue(ctx, ClientIPCtx, clientIp)
|
||||
ctx = context.WithValue(ctx, ProxyAddressesCtx, proxies)
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, RemoteAddressCtx, r.RemoteAddr)
|
||||
if h == "" {
|
||||
clientIp, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
ctx = context.WithValue(ctx, ClientIPCtx, clientIp)
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func GetClientIp(ctx context.Context) string {
|
||||
s, ok := ctx.Value(ClientIPCtx).(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func GetAccessToken(ctx context.Context) string {
|
||||
token, ok := ctx.Value("access_token").(string)
|
||||
if !ok {
|
||||
log.Printf("cannot get access token from context")
|
||||
return ""
|
||||
}
|
||||
return token
|
||||
}
|
||||
@ -1,95 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/spf13/viper"
|
||||
"log"
|
||||
)
|
||||
|
||||
type Configuration struct {
|
||||
Server ServerConfig
|
||||
OpenId OpenIDConfig
|
||||
Caps RDGCapsConfig
|
||||
Security SecurityConfig
|
||||
Client ClientConfig
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
GatewayAddress string
|
||||
Port int
|
||||
CertFile string
|
||||
KeyFile string
|
||||
Hosts []string
|
||||
RoundRobin bool
|
||||
SessionKey string
|
||||
SessionEncryptionKey string
|
||||
}
|
||||
|
||||
type OpenIDConfig struct {
|
||||
ProviderUrl string
|
||||
ClientId string
|
||||
ClientSecret string
|
||||
}
|
||||
|
||||
type RDGCapsConfig struct {
|
||||
SmartCardAuth bool
|
||||
TokenAuth bool
|
||||
IdleTimeout int
|
||||
RedirectAll bool
|
||||
DisableRedirect bool
|
||||
EnableClipboard bool
|
||||
EnablePrinter bool
|
||||
EnablePort bool
|
||||
EnablePnp bool
|
||||
EnableDrive bool
|
||||
}
|
||||
|
||||
type SecurityConfig struct {
|
||||
PAATokenEncryptionKey string
|
||||
PAATokenSigningKey string
|
||||
UserTokenEncryptionKey string
|
||||
UserTokenSigningKey string
|
||||
VerifyClientIp bool
|
||||
EnableUserToken bool
|
||||
}
|
||||
|
||||
type ClientConfig struct {
|
||||
NetworkAutoDetect int
|
||||
BandwidthAutoDetect int
|
||||
ConnectionType int
|
||||
UsernameTemplate string
|
||||
SplitUserDomain bool
|
||||
DefaultDomain string
|
||||
}
|
||||
|
||||
func init() {
|
||||
viper.SetDefault("server.certFile", "server.pem")
|
||||
viper.SetDefault("server.keyFile", "key.pem")
|
||||
viper.SetDefault("server.port", 443)
|
||||
viper.SetDefault("client.networkAutoDetect", 1)
|
||||
viper.SetDefault("client.bandwidthAutoDetect", 1)
|
||||
viper.SetDefault("security.verifyClientIp", true)
|
||||
}
|
||||
|
||||
func Load(configFile string) Configuration {
|
||||
var conf Configuration
|
||||
|
||||
viper.SetConfigName("rdpgw")
|
||||
viper.SetConfigFile(configFile)
|
||||
viper.AddConfigPath(".")
|
||||
viper.SetEnvPrefix("RDPGW")
|
||||
viper.AutomaticEnv()
|
||||
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
log.Fatalf("No config file found (%s)", err)
|
||||
}
|
||||
|
||||
if err := viper.Unmarshal(&conf); err != nil {
|
||||
log.Fatalf("Cannot unmarshal the config file; %s", err)
|
||||
}
|
||||
|
||||
if len(conf.Security.PAATokenSigningKey) < 32 {
|
||||
log.Fatalf("Token signing key not long enough")
|
||||
}
|
||||
|
||||
return conf
|
||||
}
|
||||
11
debian/changelog
vendored
Normal file
11
debian/changelog
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
rdpgw (2.0.3) UNRELEASED; urgency=medium
|
||||
|
||||
* add Dynamic Gateway
|
||||
|
||||
-- Lierfang Support Team <itsupport@lierfang.com> Fri, 13 Jun 2025 15:56:06 +0800
|
||||
|
||||
rdpgw (2.0.2) UNSTABLE; urgency=medium
|
||||
|
||||
* init
|
||||
|
||||
-- Jiangcuo <Jiangcuo@lierfang.com> Mon, 03 Feb 2025 13:56:49 +0800
|
||||
21
debian/control
vendored
Normal file
21
debian/control
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
Source: rdpgw
|
||||
Section: admin
|
||||
Priority: optional
|
||||
Maintainer: Lierfang <it_support@lierfang.com>
|
||||
Homepage: https://github.com/bolkedebruin/rdpgw
|
||||
Build-Depends: debhelper-compat (= 12),
|
||||
golang ( >= 1.23.5-1~bpo12+1 ),
|
||||
build-essential,
|
||||
libpam0g-dev,
|
||||
dh-golang
|
||||
|
||||
Package: rdpgw
|
||||
Architecture: any
|
||||
Depends: dbus,
|
||||
openssl,
|
||||
${misc:Depends},
|
||||
${shlibs:Depends},
|
||||
Description: rdpgw
|
||||
RDPGW is an implementation of the Remote Desktop Gateway protocol.
|
||||
This allows you to connect with the official Microsoft clients to remote desktops over HTTPS.
|
||||
These desktops could be, for example, XRDP desktops running in containers on Kubernetes.
|
||||
11
debian/copyright
vendored
Normal file
11
debian/copyright
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||
Source: https://github.com/bolkedebruin/rdpgw
|
||||
|
||||
|
||||
Files: *
|
||||
Copyright: bolkedebruin
|
||||
License: Apache-2.0
|
||||
|
||||
Files: debian/*
|
||||
Copyright: Lierfang <service@lierfang.com>
|
||||
License: Apache-2.0
|
||||
5
debian/install
vendored
Normal file
5
debian/install
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
bin/rdpgw usr/bin/
|
||||
bin/rdpgw-auth usr/sbin/
|
||||
debian/rdpgw.yaml etc/rdpgw/
|
||||
debian/rdpgw-auth.yaml etc/rdpgw/
|
||||
debian/rdpgw-auth.service lib/systemd/system/
|
||||
21
debian/postinst
vendored
Normal file
21
debian/postinst
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
#DEBHELPER#
|
||||
|
||||
case "$1" in
|
||||
configure)
|
||||
if [ ! -f "/etc/rdpgw/server.pem" ]; then
|
||||
random=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1)
|
||||
openssl genrsa -des3 -passout pass:$random -out /tmp/server.pass.key 2048
|
||||
openssl rsa -passin pass:$random -in /tmp/server.pass.key -out /etc/rdpgw/key.pem
|
||||
rm /tmp/server.pass.key
|
||||
openssl req -new -sha256 -key /etc/rdpgw/key.pem -out /etc/rdpgw/server.csr -subj "/C=US/ST=VA/L=SomeCity/O=MyCompany/OU=MyDivision/CN=rdpgw"
|
||||
openssl x509 -req -days 365 -in /etc/rdpgw/server.csr -signkey /etc/rdpgw/key.pem -out /etc/rdpgw/server.pem
|
||||
fi
|
||||
deb-systemd-invoke reload-or-try-restart rdpgw-auth.service || true
|
||||
deb-systemd-invoke reload-or-try-restart rdpgw.service || true
|
||||
;;
|
||||
|
||||
esac
|
||||
16
debian/rdpgw-auth.service
vendored
Normal file
16
debian/rdpgw-auth.service
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=RDP Gateway Auth Service
|
||||
After=network.target
|
||||
StartLimitBurst=5
|
||||
StartLimitInterval=10s
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
ExecStart=/usr/sbin/rdpgw-auth -c /etc/rdpgw/rdpgw-auth.yaml -s /run/rdpgw-auth.sock
|
||||
Restart=on-failure
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
6
debian/rdpgw-auth.yaml
vendored
Normal file
6
debian/rdpgw-auth.yaml
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
PXVDI:
|
||||
Enabled: true
|
||||
apiUrl: "https://10.13.16.164:3002"
|
||||
apiKey: "dasdasdasdas"
|
||||
Users:
|
||||
- {Username: "debian-rdpgw-start", Password: "debian-rdpgw-password"}
|
||||
16
debian/rdpgw.service
vendored
Normal file
16
debian/rdpgw.service
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=RDP Gateway Service
|
||||
After=network.target
|
||||
StartLimitBurst=5
|
||||
StartLimitInterval=10s
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
ExecStart=/usr/bin/rdpgw -c /etc/rdpgw/rdpgw.yaml
|
||||
Restart=on-failure
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
30
debian/rdpgw.yaml
vendored
Normal file
30
debian/rdpgw.yaml
vendored
Normal file
@ -0,0 +1,30 @@
|
||||
Server:
|
||||
Authentication:
|
||||
- ntlm
|
||||
BasicAuthTimeout: 5
|
||||
AuthSocket: /run/rdpgw-auth.sock
|
||||
GatewayAddress: localhost
|
||||
Port: 443
|
||||
Hosts:
|
||||
- localhost:3389
|
||||
HostSelection: any
|
||||
Tls: enable
|
||||
CertFile: /etc/rdpgw/server.pem
|
||||
KeyFile: /etc/rdpgw/key.pem
|
||||
Caps:
|
||||
SmartCardAuth: false
|
||||
TokenAuth: false
|
||||
IdleTimeout: 10
|
||||
EnablePrinter: true
|
||||
EnablePort: true
|
||||
EnablePnp: true
|
||||
EnableDrive: true
|
||||
EnableClipboard: true
|
||||
Client:
|
||||
UsernameTemplate: "{{ username }}"
|
||||
SplitUserDomain: false
|
||||
Security:
|
||||
PAATokenSigningKey: thisisasessionkeyreplacethisjetzt
|
||||
UserTokenEncryptionKey: thisisasessionkeyreplacethisjetzt
|
||||
EnableUserToken: false
|
||||
VerifyClientIp: trufalsee
|
||||
19
debian/rules
vendored
Executable file
19
debian/rules
vendored
Executable file
@ -0,0 +1,19 @@
|
||||
#!/usr/bin/make -f
|
||||
|
||||
# Uncomment this to turn on verbose mode.
|
||||
#export DH_VERBOSE=1
|
||||
|
||||
include /usr/share/dpkg/pkg-info.mk
|
||||
|
||||
%:
|
||||
dh $@
|
||||
|
||||
override_dh_auto_build:
|
||||
|
||||
override_dh_auto_test:
|
||||
|
||||
override_dh_auto_install:
|
||||
|
||||
override_dh_auto_clean:
|
||||
|
||||
override_dh_dwz:
|
||||
12
dev/docker-distroless/Dockerfile
Normal file
12
dev/docker-distroless/Dockerfile
Normal file
@ -0,0 +1,12 @@
|
||||
FROM golang:1
|
||||
WORKDIR /src
|
||||
ENV CGO_ENABLED 0
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN go build github.com/bolkedebruin/rdpgw/cmd/rdpgw
|
||||
|
||||
FROM gcr.io/distroless/static-debian11:nonroot
|
||||
WORKDIR /config
|
||||
COPY --from=0 /src/rdpgw /rdpgw
|
||||
CMD ["/rdpgw"]
|
||||
@ -1,36 +1,49 @@
|
||||
FROM debian:buster-slim
|
||||
# builder stage
|
||||
FROM golang:1.22-alpine as builder
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y git golang openssl curl && \
|
||||
#RUN apt-get update && apt-get install -y libpam-dev
|
||||
RUN apk --no-cache add git gcc musl-dev linux-pam-dev openssl
|
||||
|
||||
# add user
|
||||
RUN adduser --disabled-password --gecos "" --home /opt/rdpgw --uid 1001 rdpgw
|
||||
|
||||
# certificate
|
||||
RUN mkdir -p /opt/rdpgw && cd /opt/rdpgw && \
|
||||
random=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1) && \
|
||||
openssl genrsa -des3 -passout pass:$random -out server.pass.key 2048 && \
|
||||
openssl rsa -passin pass:$random -in server.pass.key -out key.pem && \
|
||||
rm server.pass.key && \
|
||||
openssl req -new -sha256 -key key.pem -out server.csr \
|
||||
-subj "/C=US/ST=VA/L=SomeCity/O=MyCompany/OU=MyDivision/CN=localhost" && \
|
||||
-subj "/C=US/ST=VA/L=SomeCity/O=MyCompany/OU=MyDivision/CN=rdpgw" && \
|
||||
openssl x509 -req -days 365 -in server.csr -signkey key.pem -out server.pem
|
||||
|
||||
RUN curl -L https://dl.google.com/go/go1.14.7.linux-amd64.tar.gz -o golang.tgz && \
|
||||
tar zxvf golang.tgz && rm golang.tgz
|
||||
# build rdpgw and set rights
|
||||
ARG CACHEBUST
|
||||
RUN git clone https://github.com/bolkedebruin/rdpgw.git /app && \
|
||||
cd /app && \
|
||||
go mod tidy -compat=1.19 && \
|
||||
CGO_ENABLED=0 GOOS=linux go build -trimpath -tags '' -ldflags '' -o '/opt/rdpgw/rdpgw' ./cmd/rdpgw && \
|
||||
CGO_ENABLED=1 GOOS=linux go build -trimpath -tags '' -ldflags '' -o '/opt/rdpgw/rdpgw-auth' ./cmd/auth && \
|
||||
chmod +x /opt/rdpgw/rdpgw && \
|
||||
chmod +x /opt/rdpgw/rdpgw-auth && \
|
||||
chmod u+s /opt/rdpgw/rdpgw-auth
|
||||
|
||||
RUN git clone https://github.com/bolkedebruin/rdpgw.git && \
|
||||
cd rdpgw && \
|
||||
env GOOS=linux GOARCH=amd64 GOROOT=/go /go/bin/go build && \
|
||||
mkdir -p /opt/rdpgw && \
|
||||
mv rdpgw /opt/rdpgw/rdpgw && \
|
||||
rm -rf /root/go && \
|
||||
rm -rf /rdpgw
|
||||
FROM alpine:latest
|
||||
|
||||
RUN rm -rf /go
|
||||
RUN apk --no-cache add linux-pam musl
|
||||
|
||||
COPY rdpgw.yaml /opt/rdpgw/rdpgw.yaml
|
||||
# make tempdir in case filestore is used
|
||||
ADD tmp.tar /
|
||||
|
||||
RUN useradd -m -d /opt/rdpgw -u 1001 -c "rdgw" rdgw && \
|
||||
mv server.pem /opt/rdpgw/server.pem && \
|
||||
mv key.pem /opt/rdpgw/key.pem && \
|
||||
chown -R 1001 /opt/rdpgw && \
|
||||
chmod +x /opt/rdpgw/rdpgw
|
||||
COPY --chown=0 rdpgw-pam /etc/pam.d/rdpgw
|
||||
|
||||
USER 1001
|
||||
COPY --chown=1001 run.sh run.sh
|
||||
COPY --chown=1001 --from=builder /opt/rdpgw /opt/rdpgw
|
||||
COPY --chown=1001 --from=builder /etc/passwd /etc/passwd
|
||||
COPY --chown=1001 --from=builder /etc/ssl/certs /etc/ssl/certs
|
||||
|
||||
USER 0
|
||||
|
||||
WORKDIR /opt/rdpgw
|
||||
ENTRYPOINT /opt/rdpgw/rdpgw
|
||||
ENTRYPOINT ["/bin/sh", "/run.sh"]
|
||||
|
||||
7
dev/docker/Dockerfile.xrdp
Normal file
7
dev/docker/Dockerfile.xrdp
Normal file
@ -0,0 +1,7 @@
|
||||
FROM rattydave/docker-ubuntu-xrdp-mate-custom:latest
|
||||
|
||||
RUN cd /etc/xrdp/ && \
|
||||
openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 3650 \
|
||||
-subj "/C=US/ST=VA/L=SomeCity/O=MyCompany/OU=MyDivision/CN=xrdp"
|
||||
|
||||
COPY xrdp.ini /etc/xrdp/xrdp.ini
|
||||
67
dev/docker/docker-compose-arm64.yml
Normal file
67
dev/docker/docker-compose-arm64.yml
Normal file
@ -0,0 +1,67 @@
|
||||
version: '3.4'
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
driver: local
|
||||
realm-export.json:
|
||||
|
||||
services:
|
||||
keycloak:
|
||||
container_name: keycloak
|
||||
image: richardjkendall/keycloak-arm:latest
|
||||
hostname: keycloak
|
||||
volumes:
|
||||
- ${PWD}/realm-export.json:/export/realm-export.json
|
||||
environment:
|
||||
KEYCLOAK_USER: admin
|
||||
KEYCLOAK_PASSWORD: admin
|
||||
KEYCLOAK_IMPORT: /export/realm-export.json
|
||||
ports:
|
||||
- 8080:8080
|
||||
restart: on-failure
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/auth"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 5s
|
||||
xrdp:
|
||||
container_name: xrdp
|
||||
hostname: xrdp
|
||||
image: bolkedebruin/docker-ubuntu-xrdp-mate-rdpgw:latest
|
||||
ports:
|
||||
- 3389:3389
|
||||
restart: on-failure
|
||||
volumes:
|
||||
- ${PWD}/xrdp_users.txt:/root/createusers.txt
|
||||
environment:
|
||||
TZ: "Europe/Amsterdam"
|
||||
rdpgw:
|
||||
container_name: rdpgw
|
||||
hostname: rdpgw
|
||||
image: bolkedebruin/rdpgw:latest
|
||||
build: .
|
||||
ports:
|
||||
- 9443:9443
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
RDPGW_SERVER__SESSION_STORE: file
|
||||
RDPGW_SERVER__CERT_FILE: /opt/rdpgw/server.pem
|
||||
RDPGW_SERVER__KEY_FILE: /opt/rdpgw/key.pem
|
||||
RDPGW_SERVER__GATEWAY_ADDRESS: localhost:9443
|
||||
RDPGW_SERVER__PORT: 9443
|
||||
RDPGW_SERVER__HOSTS: xrdp:3389
|
||||
RDPGW_SERVER__ROUND_ROBIN: "false"
|
||||
RDPGW_OPEN_ID__PROVIDER_URL: "http://keycloak:8080/auth/realms/rdpgw"
|
||||
RDPGW_OPEN_ID__CLIENT_ID: rdpgw
|
||||
RDPGW_OPEN_ID__CLIENT_SECRET: 01cd304c-6f43-4480-9479-618eb6fd578f
|
||||
RDPGW_CLIENT__USERNAME_TEMPLATE: "{{ username }}"
|
||||
RDPGW_CAPS__TOKEN_AUTH: "true"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://keycloak:8080"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
39
dev/docker/docker-compose-local.yml
Normal file
39
dev/docker/docker-compose-local.yml
Normal file
@ -0,0 +1,39 @@
|
||||
version: '3.4'
|
||||
|
||||
services:
|
||||
xrdp:
|
||||
container_name: xrdp
|
||||
hostname: xrdp
|
||||
image: bolkedebruin/docker-ubuntu-xrdp-mate-rdpgw:latest
|
||||
ports:
|
||||
- 3389:3389
|
||||
restart: on-failure
|
||||
volumes:
|
||||
- ${PWD}/xrdp_users.txt:/root/createusers.txt
|
||||
environment:
|
||||
TZ: "Europe/Amsterdam"
|
||||
rdpgw:
|
||||
container_name: rdpgw
|
||||
hostname: rdpgw
|
||||
image: bolkedebruin/rdpgw:latest
|
||||
build: .
|
||||
ports:
|
||||
- 9443:9443
|
||||
restart: on-failure
|
||||
volumes:
|
||||
- ${PWD}/xrdp_users.txt:/root/createusers.txt
|
||||
environment:
|
||||
RDPGW_SERVER__SESSION_STORE: file
|
||||
RDPGW_SERVER__CERT_FILE: /opt/rdpgw/server.pem
|
||||
RDPGW_SERVER__KEY_FILE: /opt/rdpgw/key.pem
|
||||
RDPGW_SERVER__GATEWAY_ADDRESS: localhost:9443
|
||||
RDPGW_SERVER__PORT: 9443
|
||||
RDPGW_SERVER__HOSTS: xrdp:3389
|
||||
RDPGW_SERVER__ROUND_ROBIN: "false"
|
||||
RDPGW_SERVER__AUTHENTICATION: local
|
||||
RDPGW_CAPS__TOKEN_AUTH: "false"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9443/"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
@ -7,26 +7,33 @@ volumes:
|
||||
|
||||
services:
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:11.0.0
|
||||
container_name: keycloak
|
||||
image: quay.io/keycloak/keycloak:latest
|
||||
hostname: keycloak
|
||||
volumes:
|
||||
- ${PWD}/realm-export.json:/export/realm-export.json
|
||||
- ${PWD}/realm-export.json:/opt/keycloak/data/import/realm-export.json
|
||||
environment:
|
||||
KEYCLOAK_USER: admin
|
||||
KEYCLOAK_PASSWORD: admin
|
||||
KEYCLOAK_IMPORT: /export/realm-export.json
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||
ports:
|
||||
- 8080:8080
|
||||
restart: on-failure
|
||||
command:
|
||||
- start-dev
|
||||
- --import-realm
|
||||
- --http-relative-path=/auth
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/auth"]
|
||||
interval: 30s
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 5s
|
||||
xrdp:
|
||||
container_name: xrdp
|
||||
hostname: xrdp
|
||||
image: rattydave/docker-ubuntu-xrdp-mate-custom:20.04
|
||||
image: bolkedebruin/docker-ubuntu-xrdp-mate-rdpgw:latest
|
||||
ports:
|
||||
- 3389:3389
|
||||
restart: on-failure
|
||||
@ -34,3 +41,29 @@ services:
|
||||
- ${PWD}/xrdp_users.txt:/root/createusers.txt
|
||||
environment:
|
||||
TZ: "Europe/Amsterdam"
|
||||
rdpgw:
|
||||
build: .
|
||||
ports:
|
||||
- 9443:9443
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
RDPGW_SERVER__SESSION_STORE: file
|
||||
RDPGW_SERVER__CERT_FILE: /opt/rdpgw/server.pem
|
||||
RDPGW_SERVER__KEY_FILE: /opt/rdpgw/key.pem
|
||||
RDPGW_SERVER__GATEWAY_ADDRESS: localhost:9443
|
||||
RDPGW_SERVER__PORT: 9443
|
||||
RDPGW_SERVER__HOSTS: xrdp:3389
|
||||
RDPGW_SERVER__ROUND_ROBIN: "false"
|
||||
RDPGW_OPEN_ID__PROVIDER_URL: "http://keycloak:8080/auth/realms/rdpgw"
|
||||
RDPGW_OPEN_ID__CLIENT_ID: rdpgw
|
||||
RDPGW_OPEN_ID__CLIENT_SECRET: 01cd304c-6f43-4480-9479-618eb6fd578f
|
||||
RDPGW_CLIENT__USERNAME_TEMPLATE: "{{ username }}"
|
||||
RDPGW_CAPS__TOKEN_AUTH: "true"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://keycloak:8080"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
|
||||
35
dev/docker/docker-readme.md
Normal file
35
dev/docker/docker-readme.md
Normal file
@ -0,0 +1,35 @@
|
||||
# RDPGW
|
||||
## What is RDPGW?
|
||||
Remote Desktop Gateway (RDPGW, RDG or RD Gateway) provides a secure encrypted connection
|
||||
to user desktops via RDP. It enhances control by removing all remote user direct access to
|
||||
your system and replaces it with a point-to-point remote desktop connection.
|
||||
|
||||
## How to use this image
|
||||
The remote desktop gateway relies on an OpenID Connect authentication service, such as Keycloak,
|
||||
Azure AD or Google, and a backend remote desktop service such as XRDP, gnome-remote-desktop, or
|
||||
Windows VMs. Make sure that these services have been properly setup and can be reached from
|
||||
where you will run this image.
|
||||
|
||||
This image works stateless, which means it does not store any state by default. In case you configure
|
||||
the session store to be a `filestore` a little bit of session information is stored temporarily. This means
|
||||
that a load balancer would need to maintain state for a while, which typically is the case.
|
||||
|
||||
Session and token encryption keys will be randomized on startup. As a consequence sessions will be
|
||||
invalidated on restarts and if you are load balancing the different instances will not be able to share
|
||||
user sessions. Make sure to set these encryption keys to something static, so they can be shared
|
||||
across the different instances if this is not what you want.
|
||||
|
||||
## Configuration through environment variables
|
||||
```bash
|
||||
docker --run name rdpgw bolkedebruin/rdpgw:latest \
|
||||
-e RDPGW_SERVER__CERT_FILE=/etc/rdpgw/cert.pem
|
||||
-e RDPGW_SERVER__KEY_FILE=/etc/rdpgw.cert.pem
|
||||
-e RDPGW_SERVER__GATEWAY_ADDRESS=https://localhost:443
|
||||
-e RDPGW_SERVER__SESSION_KEY=thisisasessionkeyreplacethisjetz # 32 characters
|
||||
-e RDPGW_SERVER__SESSION_ENCRYPTION_KEY=thisisasessionkeyreplacethisnunu # 32 characters
|
||||
-e RDPGW_OPEN_ID__PROVIDER_URL=http://keycloak:8080/auth/realms/rdpgw
|
||||
-e RDPGW_OPEN_ID__CLIENT_ID=rdpgw
|
||||
-e RDPGW_OPEN_ID__CLIENT_SECRET=01cd304c-6f43-4480-9479-618eb6fd578f
|
||||
-e RDPGW_SECURITY__SECURITY_PAA_TOKEN_SIGNING_KEY=prettypleasereplacemeinproductio # 32 characters
|
||||
-v conf:/etc/rdpgw
|
||||
```
|
||||
3
dev/docker/rdpgw-pam
Normal file
3
dev/docker/rdpgw-pam
Normal file
@ -0,0 +1,3 @@
|
||||
# basic PAM configuration for rdpgw on Alpine
|
||||
auth include base-auth
|
||||
auth include base-account
|
||||
@ -1,21 +1,20 @@
|
||||
server:
|
||||
certFile: /opt/rdpgw/server.pem
|
||||
keyFile: /opt/rdpgw/key.pem
|
||||
gatewayAddress: localhost:9443
|
||||
port: 9443
|
||||
hosts:
|
||||
Server:
|
||||
CertFile: /opt/rdpgw/server.pem
|
||||
KeyFile: /opt/rdpgw/key.pem
|
||||
GatewayAddress: localhost:9443
|
||||
Port: 9443
|
||||
Hosts:
|
||||
- xrdp:3389
|
||||
roundRobin: false
|
||||
sessionKey: thisisasessionkeyreplacethisjetz
|
||||
sessionEncryptionKey: thisisasessionkeyreplacethisnunu
|
||||
openId:
|
||||
providerUrl: http://keycloak:8080/auth/realms/rdpgw
|
||||
clientId: rdpgw
|
||||
clientSecret: 01cd304c-6f43-4480-9479-618eb6fd578f
|
||||
client:
|
||||
usernameTemplate: "{{ username }}"
|
||||
networkAutoDetect: 0
|
||||
bandwidthAutoDetect: 1
|
||||
ConnectionType: 6
|
||||
security:
|
||||
RoundRobin: false
|
||||
SessionKey: thisisasessionkeyreplacethisjetz
|
||||
SessionEncryptionKey: thisisasessionkeyreplacethisnunu
|
||||
OpenId:
|
||||
ProviderUrl: http://keycloak:8080/auth/realms/rdpgw
|
||||
ClientId: rdpgw
|
||||
ClientSecret: 01cd304c-6f43-4480-9479-618eb6fd578f
|
||||
Client:
|
||||
UsernameTemplate: "{{ username }}"
|
||||
Security:
|
||||
PAATokenSigningKey: prettypleasereplacemeinproductio
|
||||
Caps:
|
||||
TokenAuth: true
|
||||
|
||||
34
dev/docker/run.sh
Executable file
34
dev/docker/run.sh
Executable file
@ -0,0 +1,34 @@
|
||||
#!/bin/sh
|
||||
|
||||
USER=rdpgw
|
||||
|
||||
file="/root/createusers.txt"
|
||||
if [ -f $file ]
|
||||
then
|
||||
while IFS=: read -r username password is_sudo
|
||||
do
|
||||
echo "Username: $username, Password: **** , Sudo: $is_sudo"
|
||||
|
||||
if getent passwd "$username" > /dev/null 2>&1
|
||||
then
|
||||
echo "User Exists"
|
||||
else
|
||||
adduser -s /sbin/nologin "$username"
|
||||
echo "$username:$password" | chpasswd
|
||||
fi
|
||||
done <"$file"
|
||||
fi
|
||||
|
||||
cd /opt/rdpgw || exit 1
|
||||
|
||||
if [ -n "${RDPGW_SERVER__AUTHENTICATION}" ]; then
|
||||
if [ "${RDPGW_SERVER__AUTHENTICATION}" = "local" ]; then
|
||||
echo "Starting rdpgw-auth"
|
||||
/opt/rdpgw/rdpgw-auth &
|
||||
fi
|
||||
fi
|
||||
|
||||
# drop privileges and run the application
|
||||
su -c /opt/rdpgw/rdpgw "${USER}" -- "$@" &
|
||||
wait
|
||||
exit $?
|
||||
BIN
dev/docker/tmp.tar
Normal file
BIN
dev/docker/tmp.tar
Normal file
Binary file not shown.
208
dev/docker/xrdp.ini
Normal file
208
dev/docker/xrdp.ini
Normal file
@ -0,0 +1,208 @@
|
||||
[Globals]
|
||||
; xrdp.ini file version number
|
||||
ini_version=1
|
||||
|
||||
; fork a new process for each incoming connection
|
||||
fork=true
|
||||
; tcp port to listen
|
||||
port=3389
|
||||
; regulate if the listening socket use socket option tcp_nodelay
|
||||
; no buffering will be performed in the TCP stack
|
||||
tcp_nodelay=true
|
||||
; regulate if the listening socket use socket option keepalive
|
||||
; if the network connection disappear without close messages the connection will be closed
|
||||
tcp_keepalive=true
|
||||
#tcp_send_buffer_bytes=32768
|
||||
#tcp_recv_buffer_bytes=32768
|
||||
|
||||
; security layer can be 'tls', 'rdp' or 'negotiate'
|
||||
; for client compatible layer
|
||||
security_layer=negotiate
|
||||
; minimum security level allowed for client
|
||||
; can be 'none', 'low', 'medium', 'high', 'fips'
|
||||
crypt_level=high
|
||||
; X.509 certificate and private key
|
||||
; openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 365
|
||||
certificate=cert.pem
|
||||
key_file=key.pem
|
||||
; set SSL protocols
|
||||
; can be comma separated list of 'SSLv3', 'TLSv1', 'TLSv1.1', 'TLSv1.2'
|
||||
ssl_protocols=TLSv1, TLSv1.1, TLSv1.2
|
||||
; set TLS cipher suites
|
||||
#tls_ciphers=HIGH
|
||||
|
||||
; Section name to use for automatic login if the client sends username
|
||||
; and password. If empty, the domain name sent by the client is used.
|
||||
; If empty and no domain name is given, the first suitable section in
|
||||
; this file will be used.
|
||||
autorun=
|
||||
|
||||
allow_channels=true
|
||||
allow_multimon=true
|
||||
bitmap_cache=true
|
||||
bitmap_compression=true
|
||||
bulk_compression=true
|
||||
#hidelogwindow=true
|
||||
max_bpp=16
|
||||
new_cursors=false
|
||||
; fastpath - can be 'input', 'output', 'both', 'none'
|
||||
use_fastpath=both
|
||||
; when true, userid/password *must* be passed on cmd line
|
||||
#require_credentials=true
|
||||
; You can set the PAM error text in a gateway setup (MAX 256 chars)
|
||||
#pamerrortxt=change your password according to policy at http://url
|
||||
|
||||
;
|
||||
; colors used by windows in RGB format
|
||||
;
|
||||
blue=009cb5
|
||||
grey=dedede
|
||||
#black=000000
|
||||
#dark_grey=808080
|
||||
#blue=08246b
|
||||
#dark_blue=08246b
|
||||
#white=ffffff
|
||||
#red=ff0000
|
||||
#green=00ff00
|
||||
#background=626c72
|
||||
|
||||
;
|
||||
; configure login screen
|
||||
;
|
||||
|
||||
; Login Screen Window Title
|
||||
#ls_title=My Login Title
|
||||
|
||||
; top level window background color in RGB format
|
||||
ls_top_window_bg_color=009cb5
|
||||
|
||||
; width and height of login screen
|
||||
ls_width=350
|
||||
ls_height=430
|
||||
|
||||
; login screen background color in RGB format
|
||||
ls_bg_color=dedede
|
||||
|
||||
; optional background image filename (bmp format).
|
||||
#ls_background_image=
|
||||
|
||||
; logo
|
||||
; full path to bmp-file or file in shared folder
|
||||
ls_logo_filename=
|
||||
ls_logo_x_pos=55
|
||||
ls_logo_y_pos=50
|
||||
|
||||
; for positioning labels such as username, password etc
|
||||
ls_label_x_pos=30
|
||||
ls_label_width=60
|
||||
|
||||
; for positioning text and combo boxes next to above labels
|
||||
ls_input_x_pos=110
|
||||
ls_input_width=210
|
||||
|
||||
; y pos for first label and combo box
|
||||
ls_input_y_pos=220
|
||||
|
||||
; OK button
|
||||
ls_btn_ok_x_pos=142
|
||||
ls_btn_ok_y_pos=370
|
||||
ls_btn_ok_width=85
|
||||
ls_btn_ok_height=30
|
||||
|
||||
; Cancel button
|
||||
ls_btn_cancel_x_pos=237
|
||||
ls_btn_cancel_y_pos=370
|
||||
ls_btn_cancel_width=85
|
||||
ls_btn_cancel_height=30
|
||||
|
||||
[Logging]
|
||||
LogFile=xrdp.log
|
||||
LogLevel=debug
|
||||
EnableSyslog=true
|
||||
SyslogLevel=error
|
||||
; LogLevel and SysLogLevel could by any of: core, error, warning, info or debug
|
||||
|
||||
[Channels]
|
||||
; Channel names not listed here will be blocked by XRDP.
|
||||
; You can block any channel by setting its value to false.
|
||||
; IMPORTANT! All channels are not supported in all use
|
||||
; cases even if you set all values to true.
|
||||
; You can override these settings on each session type
|
||||
; These settings are only used if allow_channels=true
|
||||
rdpdr=true
|
||||
rdpsnd=true
|
||||
drdynvc=true
|
||||
cliprdr=true
|
||||
rail=true
|
||||
xrdpvr=true
|
||||
tcutils=true
|
||||
|
||||
; for debugging xrdp, in section xrdp1, change port=-1 to this:
|
||||
#port=/tmp/.xrdp/xrdp_display_10
|
||||
|
||||
; for debugging xrdp, add following line to section xrdp1
|
||||
#chansrvport=/tmp/.xrdp/xrdp_chansrv_socket_7210
|
||||
|
||||
|
||||
;
|
||||
; Session types
|
||||
;
|
||||
|
||||
[Xorg]
|
||||
name=Xorg - Resizing.
|
||||
lib=libxup.so
|
||||
username=ask
|
||||
password=ask
|
||||
ip=127.0.0.1
|
||||
port=-1
|
||||
code=20
|
||||
|
||||
#[X11rdp]
|
||||
#name=X11rdp
|
||||
#lib=libxup.so
|
||||
#username=ask
|
||||
#password=ask
|
||||
#ip=127.0.0.1
|
||||
#port=-1
|
||||
#xserverbpp=24
|
||||
#code=10
|
||||
|
||||
[Xvnc]
|
||||
name=Xvnc - Screen Sharing.
|
||||
lib=libvnc.so
|
||||
username=ask
|
||||
password=ask
|
||||
ip=127.0.0.1
|
||||
port=-1
|
||||
xserverbpp=16
|
||||
#delay_ms=2000
|
||||
|
||||
[Reconnect]
|
||||
name=Reconnect
|
||||
lib=libvnc.so
|
||||
ip=127.0.0.1
|
||||
port=ask5910
|
||||
username=ask
|
||||
password=ask
|
||||
#delay_ms=2000
|
||||
|
||||
#[vnc-any]
|
||||
#name=vnc-any
|
||||
#lib=libvnc.so
|
||||
#ip=ask
|
||||
#port=ask5900
|
||||
#username=na
|
||||
#password=ask
|
||||
#pamusername=asksame
|
||||
#pampassword=asksame
|
||||
#pamsessionmng=127.0.0.1
|
||||
#delay_ms=2000
|
||||
|
||||
#[sesman-any]
|
||||
#name=sesman-any
|
||||
#lib=libvnc.so
|
||||
#ip=ask
|
||||
#port=-1
|
||||
#username=ask
|
||||
#password=ask
|
||||
#delay_ms=20
|
||||
84
docs/api-auth.md
Normal file
84
docs/api-auth.md
Normal file
@ -0,0 +1,84 @@
|
||||
# NTLM认证API集成
|
||||
|
||||
RDPGW支持通过API集成NTLM认证,允许使用您自己的用户管理系统进行认证。
|
||||
|
||||
## API模式
|
||||
|
||||
API支持两种模式:
|
||||
|
||||
1. **验证模式(verify)**:验证用户凭据是否有效
|
||||
2. **密码获取模式(getpassword)**:获取用户的明文密码,用于NTLM挑战-响应计算
|
||||
|
||||
## API要求
|
||||
|
||||
### 1. 验证模式
|
||||
|
||||
用于验证用户凭据是否有效。
|
||||
|
||||
**请求格式**:
|
||||
```
|
||||
GET https://your-api-server/api/checkperm/?username=<用户名>&password=<密码>&mode=verify
|
||||
```
|
||||
|
||||
**成功响应**:
|
||||
```json
|
||||
{
|
||||
"status": "success"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 密码获取模式
|
||||
|
||||
用于获取用户的明文密码,这是NTLM认证所必需的。
|
||||
|
||||
**请求格式**:
|
||||
```
|
||||
GET https://your-api-server/api/checkperm/?username=<用户名>&mode=getpassword
|
||||
```
|
||||
|
||||
**成功响应**:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"password": "用户的明文密码"
|
||||
}
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
在`rdpgw-auth.yaml`中配置API认证:
|
||||
|
||||
```yaml
|
||||
apiauth:
|
||||
enabled: true
|
||||
apiurl: "https://your-api-server/api/checkperm/"
|
||||
```
|
||||
|
||||
## 安全考虑
|
||||
|
||||
1. **密码安全**:API必须通过HTTPS提供,并且在内部网络中运行以确保安全性
|
||||
|
||||
2. **密码存储**:您的API服务必须能够以某种形式获取或计算用户密码,这需要安全的密码存储机制
|
||||
|
||||
3. **替代方案**:如果不想暴露明文密码,请考虑使用其他认证方式,如OpenID Connect或基本认证
|
||||
|
||||
## NTLM认证流程
|
||||
|
||||
1. 客户端(如FreeRDP)发送NTLM协商消息到RDPGW
|
||||
2. RDPGW生成挑战并发送回客户端
|
||||
3. 客户端计算响应并发送认证消息
|
||||
4. RDPGW调用API以`getpassword`模式获取用户密码
|
||||
5. RDPGW使用获取的密码计算期望的响应并与客户端响应比较
|
||||
6. 如果匹配,认证成功;否则,认证失败
|
||||
|
||||
## 优势
|
||||
|
||||
- 保持标准NTLM认证流程,客户端无需修改
|
||||
- 与您的用户管理系统集成
|
||||
- 支持所有NTLM客户端,包括标准Windows远程桌面客户端
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 您的API必须能够安全地存储和提供用户密码
|
||||
- 明文密码传输存在固有的安全风险,即使在加密通道中也是如此
|
||||
- 确保API服务器仅对RDPGW服务器可访问,并考虑实施IP限制或类似措施
|
||||
271
docs/images/flow-auth.svg
Normal file
271
docs/images/flow-auth.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 60 KiB |
232
docs/images/flow-kerberos.svg
Normal file
232
docs/images/flow-kerberos.svg
Normal file
@ -0,0 +1,232 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="500"
|
||||
height="350"
|
||||
version="1.1"
|
||||
id="svg12"
|
||||
sodipodi:docname="flow-kerberos.svg"
|
||||
xml:space="preserve"
|
||||
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs12"><rect
|
||||
x="170.78426"
|
||||
y="222.01954"
|
||||
width="83.594403"
|
||||
height="14.381833"
|
||||
id="rect25" /><rect
|
||||
x="151.90811"
|
||||
y="181.57064"
|
||||
width="73.706893"
|
||||
height="19.77502"
|
||||
id="rect24" /><rect
|
||||
x="161.79562"
|
||||
y="151.00924"
|
||||
width="124.94217"
|
||||
height="19.77502"
|
||||
id="rect23" /><rect
|
||||
x="62.920519"
|
||||
y="159.99789"
|
||||
width="170.78426"
|
||||
height="27.864801"
|
||||
id="rect22" /><rect
|
||||
x="154.6047"
|
||||
y="70.111435"
|
||||
width="114.1558"
|
||||
height="14.381833"
|
||||
id="rect20" /><rect
|
||||
x="133.93082"
|
||||
y="257.97412"
|
||||
width="213.0309"
|
||||
height="26.067072"
|
||||
id="rect18" /><rect
|
||||
x="346.96173"
|
||||
y="155.50357"
|
||||
width="102.47056"
|
||||
height="28.763666"
|
||||
id="rect17" /><rect
|
||||
x="200.44679"
|
||||
y="197.7502"
|
||||
width="212.13203"
|
||||
height="20.673885"
|
||||
id="rect16" /><rect
|
||||
x="81.796677"
|
||||
y="164.4922"
|
||||
width="157.3013"
|
||||
height="16.179562"
|
||||
id="rect15" /><rect
|
||||
x="200.44679"
|
||||
y="108.76261"
|
||||
width="95.27964"
|
||||
height="19.775021"
|
||||
id="rect14" /><rect
|
||||
x="200.44679"
|
||||
y="197.7502"
|
||||
width="212.13203"
|
||||
height="20.673885"
|
||||
id="rect16-2" /><rect
|
||||
x="81.796677"
|
||||
y="164.4922"
|
||||
width="157.3013"
|
||||
height="16.179562"
|
||||
id="rect15-6" /></defs><sodipodi:namedview
|
||||
id="namedview12"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="1.1125147"
|
||||
inkscape:cx="521.34143"
|
||||
inkscape:cy="166.73937"
|
||||
inkscape:window-width="2400"
|
||||
inkscape:window-height="1274"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="25"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg12" /><!-- Rectangles --><!-- Text --><text
|
||||
x="61.016945"
|
||||
y="43.05085"
|
||||
font-family="Arial"
|
||||
font-size="16px"
|
||||
fill="#000000"
|
||||
id="text5"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan20"
|
||||
x="61.016945"
|
||||
y="43.05085">Kerberos</tspan><tspan
|
||||
sodipodi:role="line"
|
||||
x="61.016945"
|
||||
y="63.05085"
|
||||
id="tspan22" /></text><!-- Lines --><line
|
||||
x1="134.4955"
|
||||
y1="87.286629"
|
||||
x2="288.42737"
|
||||
y2="87.286629"
|
||||
stroke="#000000"
|
||||
stroke-width="2.48139"
|
||||
id="line9" /><line
|
||||
x1="132.88857"
|
||||
y1="146.08278"
|
||||
x2="286.8204"
|
||||
y2="146.08278"
|
||||
stroke="#000000"
|
||||
stroke-width="2.48139"
|
||||
id="line9-49" /><line
|
||||
x1="132.17955"
|
||||
y1="216.35213"
|
||||
x2="286.11139"
|
||||
y2="216.35213"
|
||||
stroke="#000000"
|
||||
stroke-width="2.48139"
|
||||
id="line9-49-5" /><line
|
||||
x1="134.74902"
|
||||
y1="144.4328"
|
||||
x2="287.49759"
|
||||
y2="98.587257"
|
||||
stroke="#000000"
|
||||
stroke-width="2.37506"
|
||||
id="line9-4" /><line
|
||||
x1="134.23099"
|
||||
y1="215.71254"
|
||||
x2="286.97955"
|
||||
y2="169.86702"
|
||||
stroke="#000000"
|
||||
stroke-width="2.37506"
|
||||
id="line9-4-7" /><g
|
||||
style="overflow:hidden;fill:currentColor"
|
||||
id="g12"
|
||||
transform="matrix(0.06600417,0,0,0.05178799,19.223463,60.951852)"><path
|
||||
d="M 843.28296,870.11556 C 834.84444,729.6 738.98667,612.69333 609.37481,572.96593 687.88148,536.27259 742.4,456.53333 742.4,364.08889 c 0,-127.24148 -103.15852,-230.4 -230.4,-230.4 -127.24148,0 -230.4,103.15852 -230.4,230.4 0,92.44444 54.51852,172.1837 133.12,208.87704 C 285.10815,612.69333 189.25037,729.6 180.81185,870.11556 c -0.6637,10.9037 7.96445,20.19555 18.96297,20.19555 v 0 c 9.95555,0 18.29925,-7.77481 18.96296,-17.73037 C 227.74518,718.50667 355.65037,596.38518 512,596.38518 c 156.34963,0 284.25481,122.12149 293.35704,276.19556 0.56889,9.95556 8.91259,17.73037 18.96296,17.73037 10.99852,0 19.62667,-9.29185 18.96296,-20.19555 z M 319.52593,364.08889 c 0,-106.28741 86.18666,-192.47408 192.47407,-192.47408 106.28741,0 192.47407,86.18667 192.47407,192.47408 0,106.28741 -86.18666,192.47407 -192.47407,192.47407 -106.28741,0 -192.47407,-86.18666 -192.47407,-192.47407 z"
|
||||
id="path1" /></g><g
|
||||
style="overflow:hidden;fill:currentColor"
|
||||
id="g14"
|
||||
transform="matrix(0.04275091,0,0,0.04222869,292.71414,66.391967)"><path
|
||||
d="M 665.6,509.952 H 347.648 c -12.8,0 -21.504,8.704 -21.504,21.504 v 204.8 c 0,12.8 8.704,21.504 21.504,21.504 h 315.904 c 12.8,0 21.504,-8.704 21.504,-21.504 v -204.8 c 2.048,-12.8 -8.704,-21.504 -19.456,-21.504 z M 533.504,661.504 c 0,0 0,2.048 0,0 0,12.8 -8.704,23.552 -21.504,23.552 -12.8,0 -21.504,-8.704 -21.504,-21.504 v -2.048 c -12.8,-6.144 -21.504,-21.504 -21.504,-36.352 0,-23.552 19.456,-42.496 42.496,-42.496 23.04,0 42.496,19.456 42.496,42.496 0.512,14.848 -7.68,29.696 -20.48,36.352 z"
|
||||
fill="#ff6a00"
|
||||
id="path1-3" /><path
|
||||
d="M 981.504,492.544 C 970.752,243.2 763.904,44.544 512,44.544 c -251.904,0 -458.752,198.656 -469.504,448 v 31.744 C 48.64,778.24 256,983.04 512,983.04 c 256,0 462.848,-204.8 469.504,-458.752 z M 810.496,272.896 c -42.496,34.304 -91.648,51.2 -130.048,61.952 -23.552,-87.552 -64,-159.744 -108.544,-198.144 95.744,14.848 179.2,64 238.592,136.192 z M 452.096,136.704 C 409.6,175.104 369.152,247.296 345.6,332.8 307.2,322.048 260.096,305.152 217.6,270.848 275.456,198.656 358.4,151.552 452.096,136.704 Z M 825.344,733.696 C 808.448,718.848 786.944,706.048 765.44,693.248 735.744,678.4 720.384,708.096 748.544,720.896 768,729.6 786.944,742.4 805.888,757.248 743.936,832 656.384,881.152 556.032,891.904 c 21.504,-14.848 45.056,-40.448 64,-72.704 6.656,-16.896 -21.504,-27.648 -36.352,-2.048 -25.6,36.352 -51.2,57.344 -74.752,57.344 -21.504,0 -49.152,-21.504 -72.704,-55.296 -21.504,-31.744 -42.496,-14.848 -38.4,0 19.456,29.696 40.448,53.248 61.952,70.656 -98.304,-8.704 -183.296,-57.344 -243.2,-130.048 19.456,-14.848 38.4,-27.648 57.344,-36.352 27.648,-14.848 10.752,-42.496 -16.896,-27.648 -19.456,10.752 -40.448,23.552 -59.904,38.4 C 154.624,674.304 131.072,602.112 129.024,525.312 H 261.12 c 8.704,0 16.896,-6.656 16.896,-16.896 0,-10.24 -6.144,-16.896 -16.896,-16.896 H 130.048 c 4.096,-72.704 27.648,-140.8 68.096,-198.144 38.4,34.304 91.648,55.296 138.752,68.096 -4.096,21.504 -8.704,42.496 -10.752,66.048 0,19.456 32.256,19.456 32.256,0 16.896,-149.504 96.256,-283.648 153.6,-283.648 57.344,0 136.704,136.704 155.648,288.256 2.048,19.456 34.304,16.896 31.744,0 -2.048,-21.504 -6.656,-42.496 -10.752,-64 49.152,-12.8 102.4,-34.304 140.8,-68.096 38.4,55.296 61.952,123.904 66.048,194.048 H 763.392 c -8.704,0 -16.896,6.144 -16.896,16.896 0,10.752 6.144,16.896 16.896,16.896 H 896 c -2.048,75.776 -27.648,146.432 -70.656,205.824 z"
|
||||
fill="#ff6a00"
|
||||
id="path2-2" /><path
|
||||
d="m 512,317.952 c -59.904,0 -106.496,47.104 -106.496,106.496 v 31.744 H 448 v -31.744 c 0,-34.304 27.648,-64 64,-64 36.352,0 64,27.648 64,64 v 149.504 h 42.496 V 424.448 C 618.496,364.544 571.904,317.952 512,317.952 Z"
|
||||
fill="#ff6a00"
|
||||
id="path3-6" /></g><g
|
||||
style="overflow:hidden;fill:currentColor"
|
||||
id="g14-8"
|
||||
transform="matrix(0.04275091,0,0,0.04222869,291.65593,116.90534)"><path
|
||||
d="M 665.6,509.952 H 347.648 c -12.8,0 -21.504,8.704 -21.504,21.504 v 204.8 c 0,12.8 8.704,21.504 21.504,21.504 h 315.904 c 12.8,0 21.504,-8.704 21.504,-21.504 v -204.8 c 2.048,-12.8 -8.704,-21.504 -19.456,-21.504 z M 533.504,661.504 c 0,0 0,2.048 0,0 0,12.8 -8.704,23.552 -21.504,23.552 -12.8,0 -21.504,-8.704 -21.504,-21.504 v -2.048 c -12.8,-6.144 -21.504,-21.504 -21.504,-36.352 0,-23.552 19.456,-42.496 42.496,-42.496 23.04,0 42.496,19.456 42.496,42.496 0.512,14.848 -7.68,29.696 -20.48,36.352 z"
|
||||
fill="#ff6a00"
|
||||
id="path1-3-7" /><path
|
||||
d="M 981.504,492.544 C 970.752,243.2 763.904,44.544 512,44.544 c -251.904,0 -458.752,198.656 -469.504,448 v 31.744 C 48.64,778.24 256,983.04 512,983.04 c 256,0 462.848,-204.8 469.504,-458.752 z M 810.496,272.896 c -42.496,34.304 -91.648,51.2 -130.048,61.952 -23.552,-87.552 -64,-159.744 -108.544,-198.144 95.744,14.848 179.2,64 238.592,136.192 z M 452.096,136.704 C 409.6,175.104 369.152,247.296 345.6,332.8 307.2,322.048 260.096,305.152 217.6,270.848 275.456,198.656 358.4,151.552 452.096,136.704 Z M 825.344,733.696 C 808.448,718.848 786.944,706.048 765.44,693.248 735.744,678.4 720.384,708.096 748.544,720.896 768,729.6 786.944,742.4 805.888,757.248 743.936,832 656.384,881.152 556.032,891.904 c 21.504,-14.848 45.056,-40.448 64,-72.704 6.656,-16.896 -21.504,-27.648 -36.352,-2.048 -25.6,36.352 -51.2,57.344 -74.752,57.344 -21.504,0 -49.152,-21.504 -72.704,-55.296 -21.504,-31.744 -42.496,-14.848 -38.4,0 19.456,29.696 40.448,53.248 61.952,70.656 -98.304,-8.704 -183.296,-57.344 -243.2,-130.048 19.456,-14.848 38.4,-27.648 57.344,-36.352 27.648,-14.848 10.752,-42.496 -16.896,-27.648 -19.456,10.752 -40.448,23.552 -59.904,38.4 C 154.624,674.304 131.072,602.112 129.024,525.312 H 261.12 c 8.704,0 16.896,-6.656 16.896,-16.896 0,-10.24 -6.144,-16.896 -16.896,-16.896 H 130.048 c 4.096,-72.704 27.648,-140.8 68.096,-198.144 38.4,34.304 91.648,55.296 138.752,68.096 -4.096,21.504 -8.704,42.496 -10.752,66.048 0,19.456 32.256,19.456 32.256,0 16.896,-149.504 96.256,-283.648 153.6,-283.648 57.344,0 136.704,136.704 155.648,288.256 2.048,19.456 34.304,16.896 31.744,0 -2.048,-21.504 -6.656,-42.496 -10.752,-64 49.152,-12.8 102.4,-34.304 140.8,-68.096 38.4,55.296 61.952,123.904 66.048,194.048 H 763.392 c -8.704,0 -16.896,6.144 -16.896,16.896 0,10.752 6.144,16.896 16.896,16.896 H 896 c -2.048,75.776 -27.648,146.432 -70.656,205.824 z"
|
||||
fill="#ff6a00"
|
||||
id="path2-2-5" /><path
|
||||
d="m 512,317.952 c -59.904,0 -106.496,47.104 -106.496,106.496 v 31.744 H 448 v -31.744 c 0,-34.304 27.648,-64 64,-64 36.352,0 64,27.648 64,64 v 149.504 h 42.496 V 424.448 C 618.496,364.544 571.904,317.952 512,317.952 Z"
|
||||
fill="#ff6a00"
|
||||
id="path3-6-9" /></g><g
|
||||
style="overflow:hidden;fill:currentColor"
|
||||
id="g14-0-7"
|
||||
transform="matrix(0.04275091,0,0,0.04222869,290.08317,192.55758)"><path
|
||||
d="M 665.6,509.952 H 347.648 c -12.8,0 -21.504,8.704 -21.504,21.504 v 204.8 c 0,12.8 8.704,21.504 21.504,21.504 h 315.904 c 12.8,0 21.504,-8.704 21.504,-21.504 v -204.8 c 2.048,-12.8 -8.704,-21.504 -19.456,-21.504 z M 533.504,661.504 c 0,0 0,2.048 0,0 0,12.8 -8.704,23.552 -21.504,23.552 -12.8,0 -21.504,-8.704 -21.504,-21.504 v -2.048 c -12.8,-6.144 -21.504,-21.504 -21.504,-36.352 0,-23.552 19.456,-42.496 42.496,-42.496 23.04,0 42.496,19.456 42.496,42.496 0.512,14.848 -7.68,29.696 -20.48,36.352 z"
|
||||
fill="#ff6a00"
|
||||
id="path1-3-0-1" /><path
|
||||
d="M 981.504,492.544 C 970.752,243.2 763.904,44.544 512,44.544 c -251.904,0 -458.752,198.656 -469.504,448 v 31.744 C 48.64,778.24 256,983.04 512,983.04 c 256,0 462.848,-204.8 469.504,-458.752 z M 810.496,272.896 c -42.496,34.304 -91.648,51.2 -130.048,61.952 -23.552,-87.552 -64,-159.744 -108.544,-198.144 95.744,14.848 179.2,64 238.592,136.192 z M 452.096,136.704 C 409.6,175.104 369.152,247.296 345.6,332.8 307.2,322.048 260.096,305.152 217.6,270.848 275.456,198.656 358.4,151.552 452.096,136.704 Z M 825.344,733.696 C 808.448,718.848 786.944,706.048 765.44,693.248 735.744,678.4 720.384,708.096 748.544,720.896 768,729.6 786.944,742.4 805.888,757.248 743.936,832 656.384,881.152 556.032,891.904 c 21.504,-14.848 45.056,-40.448 64,-72.704 6.656,-16.896 -21.504,-27.648 -36.352,-2.048 -25.6,36.352 -51.2,57.344 -74.752,57.344 -21.504,0 -49.152,-21.504 -72.704,-55.296 -21.504,-31.744 -42.496,-14.848 -38.4,0 19.456,29.696 40.448,53.248 61.952,70.656 -98.304,-8.704 -183.296,-57.344 -243.2,-130.048 19.456,-14.848 38.4,-27.648 57.344,-36.352 27.648,-14.848 10.752,-42.496 -16.896,-27.648 -19.456,10.752 -40.448,23.552 -59.904,38.4 C 154.624,674.304 131.072,602.112 129.024,525.312 H 261.12 c 8.704,0 16.896,-6.656 16.896,-16.896 0,-10.24 -6.144,-16.896 -16.896,-16.896 H 130.048 c 4.096,-72.704 27.648,-140.8 68.096,-198.144 38.4,34.304 91.648,55.296 138.752,68.096 -4.096,21.504 -8.704,42.496 -10.752,66.048 0,19.456 32.256,19.456 32.256,0 16.896,-149.504 96.256,-283.648 153.6,-283.648 57.344,0 136.704,136.704 155.648,288.256 2.048,19.456 34.304,16.896 31.744,0 -2.048,-21.504 -6.656,-42.496 -10.752,-64 49.152,-12.8 102.4,-34.304 140.8,-68.096 38.4,55.296 61.952,123.904 66.048,194.048 H 763.392 c -8.704,0 -16.896,6.144 -16.896,16.896 0,10.752 6.144,16.896 16.896,16.896 H 896 c -2.048,75.776 -27.648,146.432 -70.656,205.824 z"
|
||||
fill="#ff6a00"
|
||||
id="path2-2-9-5" /><path
|
||||
d="m 512,317.952 c -59.904,0 -106.496,47.104 -106.496,106.496 v 31.744 H 448 v -31.744 c 0,-34.304 27.648,-64 64,-64 36.352,0 64,27.648 64,64 v 149.504 h 42.496 V 424.448 C 618.496,364.544 571.904,317.952 512,317.952 Z"
|
||||
fill="#ff6a00"
|
||||
id="path3-6-5-5" /></g><g
|
||||
style="overflow:hidden;fill:currentColor"
|
||||
id="g18"
|
||||
transform="matrix(0.02516607,0,0,0.02459152,94.079836,77.295599)"><path
|
||||
d="m 128,85.333333 c -46.933333,0 -85.333333,38.399997 -85.333333,85.333337 v 512 A 85.333333,85.333333 0 0 0 128,768 h 298.66667 v 85.33333 h -85.33334 v 85.33334 H 682.66667 V 853.33333 H 597.33333 V 768 H 896 c 46.93333,0 85.33333,-38.4 85.33333,-85.33333 v -512 c 0,-46.93334 -38.4,-85.333336 -85.33333,-85.333337 M 128,170.66667 h 768 v 512 H 128 M 640,213.33333 490.66667,362.66667 640,512 l 59.73333,-59.73333 -89.6,-89.6 89.6,-89.6 M 384,341.33333 l -59.73333,59.73334 89.6,89.6 -89.6,89.6 L 384,640 533.33333,490.66667"
|
||||
id="path1-8" /></g><g
|
||||
style="overflow:hidden;fill:currentColor"
|
||||
id="g19"
|
||||
transform="matrix(0.03266725,0,0,0.03617844,341.02251,197.20412)"><path
|
||||
d="M 0,139.392 409.42933,81.92 409.6,489.13067 0.384,491.52 Z M 409.30133,535.21067 409.6,942.08 0,884.18133 V 532.48 Z M 450.56,81.024 1024,0 V 487.12533 L 450.56,491.52 Z M 1024,533.33333 1023.872,1024 451.37067,944.72533 450.56,532.48 1024,533.376 Z"
|
||||
fill="#0078d7"
|
||||
id="path1-5" /></g><text
|
||||
xml:space="preserve"
|
||||
id="text20"
|
||||
style="white-space:pre;shape-inside:url(#rect20);display:inline;fill:#000000"
|
||||
transform="translate(17.078426,1.7977291)"><tspan
|
||||
x="154.60547"
|
||||
y="81.059292"
|
||||
id="tspan6">Authentication</tspan></text><path
|
||||
id="path5289"
|
||||
style="fill:#000000;stroke-width:0.110144"
|
||||
d="m 327.94236,132.02299 c 0.0606,0.0171 -3.71191,0.11032 -3.45946,5.43921 -1.81339,-0.4647 -4.67039,0.14458 -6.6981,2.42505 -2.05764,2.31413 -5.53002,3.06567 -7.03568,4.20321 0,0 0.82169,1.59402 1.5939,2.02394 0.20057,0.11165 0.4123,0.17508 0.62946,0.20238 l 0.67006,1.21071 0.0736,-1.17458 0.45687,0.8204 0.0634,-1.03363 c 0.0247,-0.009 0.0488,-0.0128 0.0736,-0.0216 l 0.39342,0.70113 0.0532,-0.88184 c 1.07968,-0.47773 2.17818,-1.2374 3.07114,-1.41671 1.59806,-0.32091 2.65787,0.33129 2.25636,4.24291 -3.06578,-2.0673 -5.34393,-1.5927 -7.31738,-0.40477 0,0 -0.50812,1.23953 0.3325,1.76369 3.50763,-1.83664 3.52516,0.94253 6.15491,1.1312 0.0221,-0.0241 0.0443,-0.0479 0.0661,-0.0723 1.97991,-2.22673 4.6186,-2.92005 6.54581,-2.62379 0.0456,-2.42769 0.95349,-3.87017 1.87063,-4.57175 0.75561,-0.56763 1.3864,-0.85491 2.0711,-0.78426 0.15219,0.041 0.2391,0.32129 0.32742,0.5132 0,1e-5 -0.0217,0.0573 -0.0279,0.0759 l 0.0279,0.003 c -0.0387,1.27274 -0.26338,2.51089 -0.27918,3.59232 -0.004,0.23655 0.002,0.4618 0.0178,0.67944 1.11291,-1.67183 2.29902,-3.14124 3.46712,-4.12725 -1.5352,-2.33582 -3.20478,-4.29403 -4.69809,-5.08499 -1.61033,-1.86095 -0.77262,-4.46043 -0.70051,-6.83055 z m 7.47734,4.76338 c 0.0721,2.37025 0.90985,4.96959 -0.70051,6.83054 -1.50705,0.79824 -3.19456,2.78545 -4.74119,5.15006 0.1149,0.47877 0.32471,0.92106 0.68528,1.3481 2.01177,1.11647 4.1666,4.00763 5.9824,7.15229 1.47575,-1.6174 3.49397,-2.45042 6.01534,-1.37701 4.15829,0.92235 3.54345,-3.12836 7.59909,-1.00471 0.84062,-0.52413 0.33503,-1.76369 0.33503,-1.76369 -1.97355,-1.18787 -4.25159,-1.6625 -7.31737,0.40476 -0.40149,-3.91156 0.65833,-4.56018 2.25636,-4.23935 0.89295,0.17931 1.99137,0.93536 3.07113,1.41316 l 0.0534,0.88183 0.39339,-0.70113 c 0.0248,0.009 0.0489,0.0132 0.0736,0.0217 l 0.0634,1.03362 0.45687,-0.81677 0.0736,1.17457 0.67006,-1.21071 c 0.21716,-0.0273 0.42889,-0.0944 0.62946,-0.20601 0.77224,-0.42989 1.5939,-2.02393 1.5939,-2.02393 -1.50566,-1.13745 -4.98052,-1.8854 -7.03815,-4.19953 -2.02773,-2.28049 -4.88223,-2.89336 -6.69553,-2.42861 0.25243,-5.32881 -3.5201,-5.42212 -3.45946,-5.43921 z m -12.94929,2.50812 0.0787,0.51681 c 0,0 -0.78651,0.48492 -1.35538,0.72281 0.0624,0.13017 0.0982,0.28584 0.0965,0.45538 -0.005,0.43746 -0.25624,0.78708 -0.56345,0.78064 -0.30723,-0.006 -0.55277,-0.36487 -0.54824,-0.80232 8.2e-4,-0.0748 0.0111,-0.1485 0.0253,-0.21683 -0.48125,0.0481 -0.94926,0.0542 -0.94926,0.0542 l -0.0787,-0.51681 c 0,0 1.14374,-0.0181 1.69544,-0.18432 0.5517,-0.16625 1.59898,-0.80955 1.59898,-0.80955 z m 7.26154,4.47422 c 0.0606,0.0171 -3.71441,0.11033 -3.46205,5.43921 -1.81339,-0.46469 -4.6678,0.14821 -6.69552,2.42861 -2.05765,2.31413 -5.53251,3.06212 -7.03817,4.19954 0,0 0.82169,1.59401 1.5939,2.02393 0.20058,0.11165 0.41229,0.17869 0.62947,0.206 l 0.67005,1.2107 0.0761,-1.17457 0.45687,0.81677 0.0634,-1.03362 c 0.0247,-0.009 0.0488,-0.0128 0.0736,-0.0216 l 0.39087,0.70112 0.0532,-0.88183 c 1.07967,-0.47773 2.17819,-1.23378 3.07113,-1.41316 1.59806,-0.3209 2.65789,0.33129 2.25637,4.2429 -3.06577,-2.0673 -5.34392,-1.59625 -7.31738,-0.40838 0,0 -0.50558,1.23953 0.33503,1.76369 4.05565,-2.12369 3.4409,1.92706 7.59909,1.00471 6.11014,-2.60131 9.27192,5.99647 8.89356,9.81945 l 8.83772,-4.79229 c 0.69424,-2.21832 -5.33784,-14.94256 -9.78973,-17.30046 -1.61033,-1.86096 -0.7701,-4.46042 -0.69799,-6.83054 z m 11.16043,0.28913 c 0,0 1.04729,0.64692 1.59899,0.81316 0.55171,0.16625 1.69294,0.18433 1.69294,0.18433 l -0.0762,0.51318 c 0,0 -0.46801,-0.006 -0.94925,-0.0542 0.0143,0.0683 0.0221,0.142 0.0229,0.21684 0.004,0.43746 -0.24102,0.79586 -0.54823,0.80231 -0.30722,0.007 -0.55638,-0.34317 -0.56093,-0.78063 -0.002,-0.16954 0.0341,-0.32521 0.0965,-0.45538 -0.56883,-0.23789 -1.35539,-0.72281 -1.35539,-0.72281 l 0.0787,-0.51681 z m -16.63497,6.98236 0.0787,0.5168 c 0,0 -0.784,0.48492 -1.35281,0.72281 0.0624,0.13018 0.0982,0.28584 0.0965,0.45538 -0.005,0.43746 -0.25623,0.78708 -0.56345,0.78063 -0.30722,-0.006 -0.55276,-0.36485 -0.54823,-0.80232 8.2e-4,-0.0748 0.009,-0.14849 0.0228,-0.21684 -0.48124,0.0481 -0.94677,0.0542 -0.94677,0.0542 l -0.0787,-0.5132 c 0,0 1.14373,-0.0181 1.69542,-0.18432 0.55171,-0.16625 1.59649,-0.81315 1.59649,-0.81315 z" /><text
|
||||
xml:space="preserve"
|
||||
id="text22"
|
||||
style="white-space:pre;shape-inside:url(#rect22);fill:#000000"
|
||||
transform="rotate(-14.400077,-58.773649,-265.80494)"><tspan
|
||||
x="62.919922"
|
||||
y="170.94601"
|
||||
id="tspan7">Auth: Negotiate</tspan></text><text
|
||||
xml:space="preserve"
|
||||
id="text23"
|
||||
style="white-space:pre;shape-inside:url(#rect23);fill:#000000"
|
||||
transform="translate(-1.7977291,-1.7977291)"><tspan
|
||||
x="161.79492"
|
||||
y="161.95773"
|
||||
id="tspan8">Get TGT over proxy</tspan></text><text
|
||||
xml:space="preserve"
|
||||
id="text24"
|
||||
style="white-space:pre;shape-inside:url(#rect24);fill:#000000"
|
||||
transform="rotate(-15.585876,175.24729,55.905131)"
|
||||
inkscape:transform-center-x="33.257988"
|
||||
inkscape:transform-center-y="3.5954582"><tspan
|
||||
x="151.9082"
|
||||
y="192.51828"
|
||||
id="tspan9">TGT</tspan></text><text
|
||||
xml:space="preserve"
|
||||
id="text25"
|
||||
style="white-space:pre;shape-inside:url(#rect25);fill:#000000"
|
||||
transform="translate(22.471614,-2.6965937)"><tspan
|
||||
x="170.78516"
|
||||
y="232.9675"
|
||||
id="tspan10">Connect</tspan></text></svg>
|
||||
|
After Width: | Height: | Size: 20 KiB |
271
docs/images/flow-openid.svg
Normal file
271
docs/images/flow-openid.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 60 KiB |
218
docs/images/flow-pam.svg
Normal file
218
docs/images/flow-pam.svg
Normal file
@ -0,0 +1,218 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="500"
|
||||
height="250"
|
||||
version="1.1"
|
||||
id="svg12"
|
||||
sodipodi:docname="flow-pam.svg"
|
||||
xml:space="preserve"
|
||||
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs12"><rect
|
||||
x="168.98654"
|
||||
y="172.58199"
|
||||
width="111.4592"
|
||||
height="26.067072"
|
||||
id="rect29" /><rect
|
||||
x="341.56854"
|
||||
y="66.515976"
|
||||
width="62.021652"
|
||||
height="17.078426"
|
||||
id="rect28" /><rect
|
||||
x="376.62424"
|
||||
y="32.359123"
|
||||
width="96.178505"
|
||||
height="19.775021"
|
||||
id="rect27" /><rect
|
||||
x="170.78426"
|
||||
y="222.01955"
|
||||
width="83.594406"
|
||||
height="14.381833"
|
||||
id="rect25" /><rect
|
||||
x="151.90811"
|
||||
y="181.57063"
|
||||
width="73.706894"
|
||||
height="19.775021"
|
||||
id="rect24" /><rect
|
||||
x="161.79562"
|
||||
y="151.00925"
|
||||
width="124.94217"
|
||||
height="19.775021"
|
||||
id="rect23" /><rect
|
||||
x="62.920521"
|
||||
y="159.99789"
|
||||
width="170.78426"
|
||||
height="27.864801"
|
||||
id="rect22" /><rect
|
||||
x="154.60471"
|
||||
y="70.111435"
|
||||
width="114.1558"
|
||||
height="14.381833"
|
||||
id="rect20" /><rect
|
||||
x="133.93082"
|
||||
y="257.97412"
|
||||
width="213.0309"
|
||||
height="26.067072"
|
||||
id="rect18" /><rect
|
||||
x="346.96173"
|
||||
y="155.50357"
|
||||
width="102.47056"
|
||||
height="28.763666"
|
||||
id="rect17" /><rect
|
||||
x="200.44679"
|
||||
y="197.7502"
|
||||
width="212.13203"
|
||||
height="20.673885"
|
||||
id="rect16" /><rect
|
||||
x="81.796677"
|
||||
y="164.4922"
|
||||
width="157.3013"
|
||||
height="16.179562"
|
||||
id="rect15" /><rect
|
||||
x="200.44679"
|
||||
y="108.76261"
|
||||
width="95.27964"
|
||||
height="19.775021"
|
||||
id="rect14" /><rect
|
||||
x="200.44679"
|
||||
y="197.7502"
|
||||
width="212.13203"
|
||||
height="20.673885"
|
||||
id="rect16-2" /><rect
|
||||
x="81.796677"
|
||||
y="164.4922"
|
||||
width="157.3013"
|
||||
height="16.179562"
|
||||
id="rect15-6" /></defs><sodipodi:namedview
|
||||
id="namedview12"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="1.1125147"
|
||||
inkscape:cx="521.34143"
|
||||
inkscape:cy="166.73937"
|
||||
inkscape:window-width="2400"
|
||||
inkscape:window-height="1274"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="25"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg12" /><!-- Rectangles --><!-- Text --><text
|
||||
x="61.016945"
|
||||
y="43.05085"
|
||||
font-family="Arial"
|
||||
font-size="16px"
|
||||
fill="#000000"
|
||||
id="text5"><tspan
|
||||
sodipodi:role="line"
|
||||
x="61.016945"
|
||||
y="43.05085"
|
||||
id="tspan22">PAM(Basic/Local)</tspan></text><!-- Lines --><line
|
||||
x1="134.4955"
|
||||
y1="103.28663"
|
||||
x2="288.42737"
|
||||
y2="103.28663"
|
||||
stroke="#000000"
|
||||
stroke-width="2.48139"
|
||||
id="line9" /><line
|
||||
x1="342.65012"
|
||||
y1="104.55545"
|
||||
x2="403.99893"
|
||||
y2="104.55545"
|
||||
stroke="#000000"
|
||||
stroke-width="1.56651"
|
||||
id="line9-1" /><line
|
||||
x1="132.88857"
|
||||
y1="162.08278"
|
||||
x2="286.8204"
|
||||
y2="162.08278"
|
||||
stroke="#000000"
|
||||
stroke-width="2.48139"
|
||||
id="line9-49" /><line
|
||||
x1="134.74902"
|
||||
y1="160.4328"
|
||||
x2="287.49759"
|
||||
y2="114.58726"
|
||||
stroke="#000000"
|
||||
stroke-width="2.37506"
|
||||
id="line9-4" /><g
|
||||
style="overflow:hidden;fill:currentColor"
|
||||
id="g12"
|
||||
transform="matrix(0.06600417,0,0,0.05178799,19.223463,76.951852)"><path
|
||||
d="M 843.28296,870.11556 C 834.84444,729.6 738.98667,612.69333 609.37481,572.96593 687.88148,536.27259 742.4,456.53333 742.4,364.08889 c 0,-127.24148 -103.15852,-230.4 -230.4,-230.4 -127.24148,0 -230.4,103.15852 -230.4,230.4 0,92.44444 54.51852,172.1837 133.12,208.87704 C 285.10815,612.69333 189.25037,729.6 180.81185,870.11556 c -0.6637,10.9037 7.96445,20.19555 18.96297,20.19555 v 0 c 9.95555,0 18.29925,-7.77481 18.96296,-17.73037 C 227.74518,718.50667 355.65037,596.38518 512,596.38518 c 156.34963,0 284.25481,122.12149 293.35704,276.19556 0.56889,9.95556 8.91259,17.73037 18.96296,17.73037 10.99852,0 19.62667,-9.29185 18.96296,-20.19555 z M 319.52593,364.08889 c 0,-106.28741 86.18666,-192.47408 192.47407,-192.47408 106.28741,0 192.47407,86.18667 192.47407,192.47408 0,106.28741 -86.18666,192.47407 -192.47407,192.47407 -106.28741,0 -192.47407,-86.18666 -192.47407,-192.47407 z"
|
||||
id="path1" /></g><g
|
||||
style="overflow:hidden;fill:currentColor"
|
||||
id="g14"
|
||||
transform="matrix(0.04275091,0,0,0.04222869,292.71414,82.391967)"><path
|
||||
d="M 665.6,509.952 H 347.648 c -12.8,0 -21.504,8.704 -21.504,21.504 v 204.8 c 0,12.8 8.704,21.504 21.504,21.504 h 315.904 c 12.8,0 21.504,-8.704 21.504,-21.504 v -204.8 c 2.048,-12.8 -8.704,-21.504 -19.456,-21.504 z M 533.504,661.504 c 0,0 0,2.048 0,0 0,12.8 -8.704,23.552 -21.504,23.552 -12.8,0 -21.504,-8.704 -21.504,-21.504 v -2.048 c -12.8,-6.144 -21.504,-21.504 -21.504,-36.352 0,-23.552 19.456,-42.496 42.496,-42.496 23.04,0 42.496,19.456 42.496,42.496 0.512,14.848 -7.68,29.696 -20.48,36.352 z"
|
||||
fill="#ff6a00"
|
||||
id="path1-3" /><path
|
||||
d="M 981.504,492.544 C 970.752,243.2 763.904,44.544 512,44.544 c -251.904,0 -458.752,198.656 -469.504,448 v 31.744 C 48.64,778.24 256,983.04 512,983.04 c 256,0 462.848,-204.8 469.504,-458.752 z M 810.496,272.896 c -42.496,34.304 -91.648,51.2 -130.048,61.952 -23.552,-87.552 -64,-159.744 -108.544,-198.144 95.744,14.848 179.2,64 238.592,136.192 z M 452.096,136.704 C 409.6,175.104 369.152,247.296 345.6,332.8 307.2,322.048 260.096,305.152 217.6,270.848 275.456,198.656 358.4,151.552 452.096,136.704 Z M 825.344,733.696 C 808.448,718.848 786.944,706.048 765.44,693.248 735.744,678.4 720.384,708.096 748.544,720.896 768,729.6 786.944,742.4 805.888,757.248 743.936,832 656.384,881.152 556.032,891.904 c 21.504,-14.848 45.056,-40.448 64,-72.704 6.656,-16.896 -21.504,-27.648 -36.352,-2.048 -25.6,36.352 -51.2,57.344 -74.752,57.344 -21.504,0 -49.152,-21.504 -72.704,-55.296 -21.504,-31.744 -42.496,-14.848 -38.4,0 19.456,29.696 40.448,53.248 61.952,70.656 -98.304,-8.704 -183.296,-57.344 -243.2,-130.048 19.456,-14.848 38.4,-27.648 57.344,-36.352 27.648,-14.848 10.752,-42.496 -16.896,-27.648 -19.456,10.752 -40.448,23.552 -59.904,38.4 C 154.624,674.304 131.072,602.112 129.024,525.312 H 261.12 c 8.704,0 16.896,-6.656 16.896,-16.896 0,-10.24 -6.144,-16.896 -16.896,-16.896 H 130.048 c 4.096,-72.704 27.648,-140.8 68.096,-198.144 38.4,34.304 91.648,55.296 138.752,68.096 -4.096,21.504 -8.704,42.496 -10.752,66.048 0,19.456 32.256,19.456 32.256,0 16.896,-149.504 96.256,-283.648 153.6,-283.648 57.344,0 136.704,136.704 155.648,288.256 2.048,19.456 34.304,16.896 31.744,0 -2.048,-21.504 -6.656,-42.496 -10.752,-64 49.152,-12.8 102.4,-34.304 140.8,-68.096 38.4,55.296 61.952,123.904 66.048,194.048 H 763.392 c -8.704,0 -16.896,6.144 -16.896,16.896 0,10.752 6.144,16.896 16.896,16.896 H 896 c -2.048,75.776 -27.648,146.432 -70.656,205.824 z"
|
||||
fill="#ff6a00"
|
||||
id="path2-2" /><path
|
||||
d="m 512,317.952 c -59.904,0 -106.496,47.104 -106.496,106.496 v 31.744 H 448 v -31.744 c 0,-34.304 27.648,-64 64,-64 36.352,0 64,27.648 64,64 v 149.504 h 42.496 V 424.448 C 618.496,364.544 571.904,317.952 512,317.952 Z"
|
||||
fill="#ff6a00"
|
||||
id="path3-6" /></g><g
|
||||
style="overflow:hidden;fill:currentColor"
|
||||
id="g14-8"
|
||||
transform="matrix(0.04275091,0,0,0.04222869,292.55479,139.19739)"><path
|
||||
d="M 665.6,509.952 H 347.648 c -12.8,0 -21.504,8.704 -21.504,21.504 v 204.8 c 0,12.8 8.704,21.504 21.504,21.504 h 315.904 c 12.8,0 21.504,-8.704 21.504,-21.504 v -204.8 c 2.048,-12.8 -8.704,-21.504 -19.456,-21.504 z M 533.504,661.504 c 0,0 0,2.048 0,0 0,12.8 -8.704,23.552 -21.504,23.552 -12.8,0 -21.504,-8.704 -21.504,-21.504 v -2.048 c -12.8,-6.144 -21.504,-21.504 -21.504,-36.352 0,-23.552 19.456,-42.496 42.496,-42.496 23.04,0 42.496,19.456 42.496,42.496 0.512,14.848 -7.68,29.696 -20.48,36.352 z"
|
||||
fill="#ff6a00"
|
||||
id="path1-3-7" /><path
|
||||
d="M 981.504,492.544 C 970.752,243.2 763.904,44.544 512,44.544 c -251.904,0 -458.752,198.656 -469.504,448 v 31.744 C 48.64,778.24 256,983.04 512,983.04 c 256,0 462.848,-204.8 469.504,-458.752 z M 810.496,272.896 c -42.496,34.304 -91.648,51.2 -130.048,61.952 -23.552,-87.552 -64,-159.744 -108.544,-198.144 95.744,14.848 179.2,64 238.592,136.192 z M 452.096,136.704 C 409.6,175.104 369.152,247.296 345.6,332.8 307.2,322.048 260.096,305.152 217.6,270.848 275.456,198.656 358.4,151.552 452.096,136.704 Z M 825.344,733.696 C 808.448,718.848 786.944,706.048 765.44,693.248 735.744,678.4 720.384,708.096 748.544,720.896 768,729.6 786.944,742.4 805.888,757.248 743.936,832 656.384,881.152 556.032,891.904 c 21.504,-14.848 45.056,-40.448 64,-72.704 6.656,-16.896 -21.504,-27.648 -36.352,-2.048 -25.6,36.352 -51.2,57.344 -74.752,57.344 -21.504,0 -49.152,-21.504 -72.704,-55.296 -21.504,-31.744 -42.496,-14.848 -38.4,0 19.456,29.696 40.448,53.248 61.952,70.656 -98.304,-8.704 -183.296,-57.344 -243.2,-130.048 19.456,-14.848 38.4,-27.648 57.344,-36.352 27.648,-14.848 10.752,-42.496 -16.896,-27.648 -19.456,10.752 -40.448,23.552 -59.904,38.4 C 154.624,674.304 131.072,602.112 129.024,525.312 H 261.12 c 8.704,0 16.896,-6.656 16.896,-16.896 0,-10.24 -6.144,-16.896 -16.896,-16.896 H 130.048 c 4.096,-72.704 27.648,-140.8 68.096,-198.144 38.4,34.304 91.648,55.296 138.752,68.096 -4.096,21.504 -8.704,42.496 -10.752,66.048 0,19.456 32.256,19.456 32.256,0 16.896,-149.504 96.256,-283.648 153.6,-283.648 57.344,0 136.704,136.704 155.648,288.256 2.048,19.456 34.304,16.896 31.744,0 -2.048,-21.504 -6.656,-42.496 -10.752,-64 49.152,-12.8 102.4,-34.304 140.8,-68.096 38.4,55.296 61.952,123.904 66.048,194.048 H 763.392 c -8.704,0 -16.896,6.144 -16.896,16.896 0,10.752 6.144,16.896 16.896,16.896 H 896 c -2.048,75.776 -27.648,146.432 -70.656,205.824 z"
|
||||
fill="#ff6a00"
|
||||
id="path2-2-5" /><path
|
||||
d="m 512,317.952 c -59.904,0 -106.496,47.104 -106.496,106.496 v 31.744 H 448 v -31.744 c 0,-34.304 27.648,-64 64,-64 36.352,0 64,27.648 64,64 v 149.504 h 42.496 V 424.448 C 618.496,364.544 571.904,317.952 512,317.952 Z"
|
||||
fill="#ff6a00"
|
||||
id="path3-6-9" /></g><g
|
||||
style="overflow:hidden;fill:currentColor"
|
||||
id="g18"
|
||||
transform="matrix(0.02516607,0,0,0.02459152,94.079836,93.295599)"><path
|
||||
d="m 128,85.333333 c -46.933333,0 -85.333333,38.399997 -85.333333,85.333337 v 512 A 85.333333,85.333333 0 0 0 128,768 h 298.66667 v 85.33333 h -85.33334 v 85.33334 H 682.66667 V 853.33333 H 597.33333 V 768 H 896 c 46.93333,0 85.33333,-38.4 85.33333,-85.33333 v -512 c 0,-46.93334 -38.4,-85.333336 -85.33333,-85.333337 M 128,170.66667 h 768 v 512 H 128 M 640,213.33333 490.66667,362.66667 640,512 l 59.73333,-59.73333 -89.6,-89.6 89.6,-89.6 M 384,341.33333 l -59.73333,59.73334 89.6,89.6 -89.6,89.6 L 384,640 533.33333,490.66667"
|
||||
id="path1-8" /></g><g
|
||||
style="overflow:hidden;fill:currentColor"
|
||||
id="g19"
|
||||
transform="matrix(0.03266725,0,0,0.03617844,345.51683,142.19382)"><path
|
||||
d="M 0,139.392 409.42933,81.92 409.6,489.13067 0.384,491.52 Z M 409.30133,535.21067 409.6,942.08 0,884.18133 V 532.48 Z M 450.56,81.024 1024,0 V 487.12533 L 450.56,491.52 Z M 1024,533.33333 1023.872,1024 451.37067,944.72533 450.56,532.48 1024,533.376 Z"
|
||||
fill="#0078d7"
|
||||
id="path1-5" /></g><text
|
||||
xml:space="preserve"
|
||||
id="text20"
|
||||
style="white-space:pre;shape-inside:url(#rect20);display:inline;fill:#000000"
|
||||
transform="translate(17.078426,17.797729)"><tspan
|
||||
x="154.60547"
|
||||
y="81.059292"
|
||||
id="tspan5">Authentication</tspan></text><g
|
||||
style="overflow:hidden;fill:currentColor"
|
||||
id="g27"
|
||||
transform="matrix(0.04222519,0,0,0.03933851,410.28976,84.846267)"><path
|
||||
d="M 916.48,242.88 907.52,172.16 512,32 116.48,172.16 l -8.96,70.4 c -3.2,23.68 -68.48,578.88 365.12,736 L 512,992 551.04,977.92 C 983.36,822.4 919.68,266.56 916.48,242.88 Z m -154.88,147.2 -211.52,339.2 -2.88,4.48 A 86.4,86.4 0 0 1 428.48,761.28 87.68,87.68 0 0 1 405.12,739.84 L 268.48,557.76 A 64.03559,64.03559 0 0 1 359.04,467.2 L 459.2,544 659.2,315.2 a 64,64 0 0 1 102.72,76.16 z"
|
||||
fill="#231f20"
|
||||
id="path1-4" /></g><text
|
||||
xml:space="preserve"
|
||||
id="text27"
|
||||
style="white-space:pre;shape-inside:url(#rect27);display:inline;fill:#000000"
|
||||
transform="translate(21.572749,35.77502)"><tspan
|
||||
x="376.625"
|
||||
y="43.307339"
|
||||
id="tspan6">rdpgw-auth</tspan></text><text
|
||||
xml:space="preserve"
|
||||
id="text28"
|
||||
style="white-space:pre;shape-inside:url(#rect28);display:inline;fill:#000000"
|
||||
transform="translate(11.685239,21.393187)"><tspan
|
||||
x="341.56836"
|
||||
y="77.463589"
|
||||
id="tspan7">socket</tspan></text><text
|
||||
xml:space="preserve"
|
||||
id="text29"
|
||||
style="white-space:pre;shape-inside:url(#rect29);display:inline;fill:#000000"
|
||||
transform="translate(23.370478,-8.089781)"><tspan
|
||||
x="168.98633"
|
||||
y="183.53"
|
||||
id="tspan8">connect</tspan></text></svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
22
docs/images/flow.svg
Normal file
22
docs/images/flow.svg
Normal file
@ -0,0 +1,22 @@
|
||||
<svg width="500" height="300" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Rectangles -->
|
||||
<rect x="50" y="50" width="150" height="50" fill="lightblue" stroke="black" stroke-width="2"/>
|
||||
<rect x="200" y="50" width="150" height="50" fill="lightblue" stroke="black" stroke-width="2"/>
|
||||
<rect x="50" y="150" width="150" height="50" fill="lightblue" stroke="black" stroke-width="2"/>
|
||||
<rect x="200" y="150" width="150" height="50" fill="lightblue" stroke="black" stroke-width="2"/>
|
||||
<rect x="350" y="150" width="150" height="50" fill="lightblue" stroke="black" stroke-width="2"/>
|
||||
|
||||
<!-- Text -->
|
||||
<text x="75" y="85" font-family="Arial" font-size="16" fill="black">Client</text>
|
||||
<text x="235" y="85" font-family="Arial" font-size="16" fill="black">RDP Gateway</text>
|
||||
<text x="65" y="185" font-family="Arial" font-size="16" fill="black">RDP GW Auth</text>
|
||||
<text x="215" y="185" font-family="Arial" font-size="16" fill="black">PAM</text>
|
||||
<text x="365" y="185" font-family="Arial" font-size="16" fill="black">Passwd or LDAP</text>
|
||||
|
||||
<!-- Lines -->
|
||||
<line x1="100" y1="75" x2="200" y2="75" stroke="black" stroke-width="2"/>
|
||||
<line x1="200" y1="100" x2="100" y2="175" stroke="black" stroke-width="2"/>
|
||||
<line x1="100" y1="175" x2="200" y2="175" stroke="black" stroke-width="2"/>
|
||||
<line x1="200" y1="200" x2="350" y2="200" stroke="black" stroke-width="2"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
23
docs/rdpgw-auth-api.yaml.example
Normal file
23
docs/rdpgw-auth-api.yaml.example
Normal file
@ -0,0 +1,23 @@
|
||||
# RDPGW认证配置示例
|
||||
# 将此文件保存为rdpgw-auth.yaml并根据您的环境调整设置
|
||||
|
||||
# API认证配置
|
||||
ApiAuth:
|
||||
# 启用API认证
|
||||
enabled: true
|
||||
# API URL - 验证用户凭据的端点
|
||||
# NTLM认证需要API提供明文密码
|
||||
# 此API需要支持以下两种模式:
|
||||
# 1. ?username=user&mode=getpassword - 返回用户的明文密码
|
||||
# 2. ?username=user&password=pass&mode=verify - 验证凭据是否正确
|
||||
apiurl: "https://your-api-server/api/checkperm/"
|
||||
|
||||
# 用户配置
|
||||
# 当使用API认证时,此部分可以为空
|
||||
# 如果为特定用户提供密码,将绕过API并使用本地密码
|
||||
Users: []
|
||||
|
||||
# 或者,您也可以同时支持API认证和本地用户认证
|
||||
# 在这种情况下,请提供本地用户凭据
|
||||
# users:
|
||||
# - {Username: "local_user", Password: "local_password"}
|
||||
63
go.mod
63
go.mod
@ -1,15 +1,60 @@
|
||||
module github.com/bolkedebruin/rdpgw
|
||||
|
||||
go 1.14
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.0.0-alpha.1
|
||||
github.com/gorilla/sessions v1.2.0
|
||||
github.com/gorilla/websocket v1.4.2
|
||||
github.com/bolkedebruin/gokrb5/v8 v8.5.0
|
||||
github.com/coreos/go-oidc/v3 v3.9.0
|
||||
github.com/fatih/structs v1.1.0
|
||||
github.com/go-jose/go-jose/v4 v4.0.1
|
||||
github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/sessions v1.2.2
|
||||
github.com/gorilla/websocket v1.5.1
|
||||
github.com/jcmturner/gofork v1.7.6
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1
|
||||
github.com/knadh/koanf/parsers/yaml v0.1.0
|
||||
github.com/knadh/koanf/providers/confmap v0.1.0
|
||||
github.com/knadh/koanf/providers/env v0.1.0
|
||||
github.com/knadh/koanf/providers/file v0.1.0
|
||||
github.com/knadh/koanf/v2 v2.1.0
|
||||
github.com/m7913d/go-ntlm v0.0.1
|
||||
github.com/msteinert/pam/v2 v2.0.0
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/prometheus/client_golang v1.7.1
|
||||
github.com/spf13/cobra v1.0.0
|
||||
github.com/spf13/viper v1.7.0
|
||||
github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
|
||||
github.com/prometheus/client_golang v1.19.0
|
||||
github.com/stretchr/testify v1.9.0
|
||||
github.com/thought-machine/go-flags v1.6.3
|
||||
golang.org/x/crypto v0.31.0
|
||||
golang.org/x/oauth2 v0.18.0
|
||||
google.golang.org/grpc v1.62.1
|
||||
google.golang.org/protobuf v1.33.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.1 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/hashicorp/go-uuid v1.0.3 // indirect
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
|
||||
github.com/knadh/koanf/maps v0.1.1 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.0 // indirect
|
||||
github.com/prometheus/common v0.50.0 // indirect
|
||||
github.com/prometheus/procfs v0.13.0 // indirect
|
||||
golang.org/x/net v0.23.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240314234333-6e1732d8331c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
132
index.js
Normal file
132
index.js
Normal file
@ -0,0 +1,132 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// 配置信息
|
||||
const config = {
|
||||
port: 3000,
|
||||
// 用户信息,可以通过配置文件覆盖
|
||||
users: {
|
||||
'testuser': 'testpassword',
|
||||
'admin': 'adminpass',
|
||||
'user1': 'password1'
|
||||
},
|
||||
// API路径
|
||||
apiPath: '/api/checkperm'
|
||||
};
|
||||
|
||||
// 尝试加载配置文件
|
||||
try {
|
||||
const configPath = path.join(__dirname, 'config.json');
|
||||
if (fs.existsSync(configPath)) {
|
||||
const fileConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
// 合并配置
|
||||
Object.assign(config, fileConfig);
|
||||
console.log('已加载配置文件');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('加载配置文件失败,使用默认配置:', error.message);
|
||||
}
|
||||
|
||||
const app = express();
|
||||
|
||||
// 添加中间件解析JSON和表单数据
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// 处理认证逻辑的函数
|
||||
function handleAuth(username, password, mode) {
|
||||
// 验证参数是否存在
|
||||
if (!username) {
|
||||
console.log('缺少必要参数: username');
|
||||
return { status: 400, response: { status: 'error', message: '缺少必要参数: username' } };
|
||||
}
|
||||
|
||||
// 检查用户是否存在
|
||||
if (!config.users[username]) {
|
||||
console.log(`用户不存在: ${username}`);
|
||||
return { status: 401, response: { status: 'error', message: '用户不存在' } };
|
||||
}
|
||||
|
||||
// 根据模式处理请求
|
||||
if (mode === 'getpassword') {
|
||||
// 密码获取模式 - 用于NTLM认证
|
||||
console.log(`返回用户 ${username} 的密码`);
|
||||
return {
|
||||
status: 200,
|
||||
response: {
|
||||
status: 'success',
|
||||
password: config.users[username]
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// 默认为verify模式 - 验证用户名和密码
|
||||
if (!password) {
|
||||
console.log('缺少必要参数: password');
|
||||
return { status: 400, response: { status: 'error', message: '缺少必要参数: password' } };
|
||||
}
|
||||
|
||||
if (config.users[username] === password) {
|
||||
console.log('认证成功');
|
||||
return {
|
||||
status: 200,
|
||||
response: {
|
||||
status: 'success',
|
||||
user: username
|
||||
}
|
||||
};
|
||||
} else {
|
||||
console.log('认证失败: 密码不正确');
|
||||
return { status: 401, response: { status: 'error', message: '认证失败' } };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 认证API端点 - GET
|
||||
app.get(config.apiPath, (req, res) => {
|
||||
const { username, password, mode } = req.query;
|
||||
|
||||
console.log('收到GET认证请求:');
|
||||
console.log(`username: ${username}`);
|
||||
console.log(`mode: ${mode || 'verify'}`);
|
||||
if (password) {
|
||||
console.log(`password: ${'*'.repeat(password ? password.length : 0)}`); // 为安全起见不打印实际密码
|
||||
}
|
||||
|
||||
const result = handleAuth(username, password, mode);
|
||||
return res.status(result.status).json(result.response);
|
||||
});
|
||||
|
||||
// 认证API端点 - POST
|
||||
app.post(config.apiPath, (req, res) => {
|
||||
const { username, password, mode } = req.body;
|
||||
|
||||
console.log('收到POST认证请求:');
|
||||
console.log(`username: ${username}`);
|
||||
console.log(`mode: ${mode || 'verify'}`);
|
||||
if (password) {
|
||||
console.log(`password: ${'*'.repeat(password ? password.length : 0)}`); // 为安全起见不打印实际密码
|
||||
}
|
||||
|
||||
const result = handleAuth(username, password, mode);
|
||||
return res.status(result.status).json(result.response);
|
||||
});
|
||||
|
||||
// 根路径返回服务信息
|
||||
app.get('/', (req, res) => {
|
||||
res.send('RDPGW远程认证测试服务已启动');
|
||||
});
|
||||
|
||||
// 启动服务器
|
||||
app.listen(config.port, () => {
|
||||
console.log(`认证服务器已启动,监听端口: ${config.port}`);
|
||||
console.log('当前配置:');
|
||||
console.log(`- 端口: ${config.port}`);
|
||||
console.log(`- API路径: ${config.apiPath}`);
|
||||
console.log(`- 已配置用户数: ${Object.keys(config.users).length}`);
|
||||
console.log('\n支持的模式:');
|
||||
console.log(`1. 验证模式 (GET): http://localhost:${config.port}${config.apiPath}?username=testuser&password=testpassword&mode=verify`);
|
||||
console.log(`2. 密码获取模式 (GET): http://localhost:${config.port}${config.apiPath}?username=testuser&mode=getpassword`);
|
||||
console.log('---');
|
||||
console.log('POST请求也支持,可以通过请求体发送参数');
|
||||
});
|
||||
146
main.go
146
main.go
@ -1,146 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"github.com/bolkedebruin/rdpgw/api"
|
||||
"github.com/bolkedebruin/rdpgw/common"
|
||||
"github.com/bolkedebruin/rdpgw/config"
|
||||
"github.com/bolkedebruin/rdpgw/protocol"
|
||||
"github.com/bolkedebruin/rdpgw/security"
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/oauth2"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
var cmd = &cobra.Command{
|
||||
Use: "rdpgw",
|
||||
Long: "Remote Desktop Gateway",
|
||||
}
|
||||
|
||||
var (
|
||||
configFile string
|
||||
)
|
||||
|
||||
var conf config.Configuration
|
||||
|
||||
func main() {
|
||||
// get config
|
||||
cmd.PersistentFlags().StringVarP(&configFile, "conf", "c", "rdpgw.yaml", "config file (json, yaml, ini)")
|
||||
conf = config.Load(configFile)
|
||||
|
||||
security.VerifyClientIP = conf.Security.VerifyClientIp
|
||||
|
||||
// set security keys
|
||||
security.SigningKey = []byte(conf.Security.PAATokenSigningKey)
|
||||
security.EncryptionKey = []byte(conf.Security.PAATokenEncryptionKey)
|
||||
security.UserEncryptionKey = []byte(conf.Security.UserTokenEncryptionKey)
|
||||
security.UserSigningKey = []byte(conf.Security.UserTokenSigningKey)
|
||||
|
||||
// set oidc config
|
||||
provider, err := oidc.NewProvider(context.Background(), conf.OpenId.ProviderUrl)
|
||||
if err != nil {
|
||||
log.Fatalf("Cannot get oidc provider: %s", err)
|
||||
}
|
||||
oidcConfig := &oidc.Config{
|
||||
ClientID: conf.OpenId.ClientId,
|
||||
}
|
||||
verifier := provider.Verifier(oidcConfig)
|
||||
|
||||
oauthConfig := oauth2.Config{
|
||||
ClientID: conf.OpenId.ClientId,
|
||||
ClientSecret: conf.OpenId.ClientSecret,
|
||||
RedirectURL: "https://" + conf.Server.GatewayAddress + "/callback",
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||
}
|
||||
security.OIDCProvider = provider
|
||||
security.Oauth2Config = oauthConfig
|
||||
|
||||
api := &api.Config{
|
||||
GatewayAddress: conf.Server.GatewayAddress,
|
||||
OAuth2Config: &oauthConfig,
|
||||
OIDCTokenVerifier: verifier,
|
||||
PAATokenGenerator: security.GeneratePAAToken,
|
||||
UserTokenGenerator: security.GenerateUserToken,
|
||||
EnableUserToken: conf.Security.EnableUserToken,
|
||||
SessionKey: []byte(conf.Server.SessionKey),
|
||||
SessionEncryptionKey: []byte(conf.Server.SessionEncryptionKey),
|
||||
Hosts: conf.Server.Hosts,
|
||||
NetworkAutoDetect: conf.Client.NetworkAutoDetect,
|
||||
UsernameTemplate: conf.Client.UsernameTemplate,
|
||||
BandwidthAutoDetect: conf.Client.BandwidthAutoDetect,
|
||||
ConnectionType: conf.Client.ConnectionType,
|
||||
SplitUserDomain: conf.Client.SplitUserDomain,
|
||||
DefaultDomain: conf.Client.DefaultDomain,
|
||||
}
|
||||
api.NewApi()
|
||||
|
||||
if conf.Server.CertFile == "" || conf.Server.KeyFile == "" {
|
||||
log.Fatal("Both certfile and keyfile need to be specified")
|
||||
}
|
||||
|
||||
//mux := http.NewServeMux()
|
||||
//mux.HandleFunc("*", HelloServer)
|
||||
|
||||
log.Printf("Starting remote desktop gateway server")
|
||||
|
||||
cfg := &tls.Config{}
|
||||
tlsDebug := os.Getenv("SSLKEYLOGFILE")
|
||||
if tlsDebug != "" {
|
||||
w, err := os.OpenFile(tlsDebug, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
log.Fatalf("Cannot open key log file %s for writing %s", tlsDebug, err)
|
||||
}
|
||||
log.Printf("Key log file set to: %s", tlsDebug)
|
||||
cfg.KeyLogWriter = w
|
||||
}
|
||||
|
||||
cert, err := tls.LoadX509KeyPair(conf.Server.CertFile, conf.Server.KeyFile)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
cfg.Certificates = append(cfg.Certificates, cert)
|
||||
server := http.Server{
|
||||
Addr: ":" + strconv.Itoa(conf.Server.Port),
|
||||
TLSConfig: cfg,
|
||||
TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)), // disable http2
|
||||
}
|
||||
|
||||
// create the gateway
|
||||
handlerConfig := protocol.ServerConf{
|
||||
IdleTimeout: conf.Caps.IdleTimeout,
|
||||
TokenAuth: conf.Caps.TokenAuth,
|
||||
SmartCardAuth: conf.Caps.SmartCardAuth,
|
||||
RedirectFlags: protocol.RedirectFlags{
|
||||
Clipboard: conf.Caps.EnableClipboard,
|
||||
Drive: conf.Caps.EnableDrive,
|
||||
Printer: conf.Caps.EnablePrinter,
|
||||
Port: conf.Caps.EnablePort,
|
||||
Pnp: conf.Caps.EnablePnp,
|
||||
DisableAll: conf.Caps.DisableRedirect,
|
||||
EnableAll: conf.Caps.RedirectAll,
|
||||
},
|
||||
VerifyTunnelCreate: security.VerifyPAAToken,
|
||||
VerifyServerFunc: security.VerifyServerFunc,
|
||||
}
|
||||
gw := protocol.Gateway{
|
||||
ServerConf: &handlerConfig,
|
||||
}
|
||||
|
||||
http.Handle("/remoteDesktopGateway/", common.EnrichContext(http.HandlerFunc(gw.HandleGatewayProtocol)))
|
||||
http.Handle("/connect", common.EnrichContext(api.Authenticated(http.HandlerFunc(api.HandleDownload))))
|
||||
http.Handle("/metrics", promhttp.Handler())
|
||||
http.HandleFunc("/tokeninfo", api.TokenInfo)
|
||||
http.HandleFunc("/callback", api.HandleCallback)
|
||||
|
||||
err = server.ListenAndServeTLS("", "")
|
||||
if err != nil {
|
||||
log.Fatal("ListenAndServe: ", err)
|
||||
}
|
||||
}
|
||||
31
proto/auth.proto
Normal file
31
proto/auth.proto
Normal file
@ -0,0 +1,31 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package auth;
|
||||
|
||||
option go_package = "./auth";
|
||||
|
||||
message UserPass {
|
||||
string username = 1;
|
||||
string password = 2;
|
||||
}
|
||||
|
||||
message AuthResponse {
|
||||
bool authenticated = 1;
|
||||
string error = 2;
|
||||
}
|
||||
|
||||
message NtlmRequest {
|
||||
string session = 1;
|
||||
string ntlmMessage = 2;
|
||||
}
|
||||
|
||||
message NtlmResponse {
|
||||
bool authenticated = 1;
|
||||
string username = 2;
|
||||
string ntlmMessage = 3;
|
||||
}
|
||||
|
||||
service Authenticate {
|
||||
rpc Authenticate (UserPass) returns (AuthResponse) {}
|
||||
rpc NTLM (NtlmRequest) returns (NtlmResponse) {}
|
||||
}
|
||||
@ -1,145 +0,0 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/bolkedebruin/rdpgw/common"
|
||||
"github.com/bolkedebruin/rdpgw/transport"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/patrickmn/go-cache"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
rdgConnectionIdKey = "Rdg-Connection-Id"
|
||||
MethodRDGIN = "RDG_IN_DATA"
|
||||
MethodRDGOUT = "RDG_OUT_DATA"
|
||||
)
|
||||
|
||||
var (
|
||||
connectionCache = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Namespace: "rdpgw",
|
||||
Name: "connection_cache",
|
||||
Help: "The amount of connections in the cache",
|
||||
})
|
||||
|
||||
websocketConnections = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Namespace: "rdpgw",
|
||||
Name: "websocket_connections",
|
||||
Help: "The count of websocket connections",
|
||||
})
|
||||
|
||||
legacyConnections = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Namespace: "rdpgw",
|
||||
Name: "legacy_connections",
|
||||
Help: "The count of legacy https connections",
|
||||
})
|
||||
)
|
||||
|
||||
type Gateway struct {
|
||||
ServerConf *ServerConf
|
||||
}
|
||||
|
||||
var upgrader = websocket.Upgrader{}
|
||||
var c = cache.New(5*time.Minute, 10*time.Minute)
|
||||
|
||||
func init() {
|
||||
prometheus.MustRegister(connectionCache)
|
||||
prometheus.MustRegister(legacyConnections)
|
||||
prometheus.MustRegister(websocketConnections)
|
||||
}
|
||||
|
||||
func (g *Gateway) HandleGatewayProtocol(w http.ResponseWriter, r *http.Request) {
|
||||
connectionCache.Set(float64(c.ItemCount()))
|
||||
|
||||
var s *SessionInfo
|
||||
|
||||
connId := r.Header.Get(rdgConnectionIdKey)
|
||||
x, found := c.Get(connId)
|
||||
if !found {
|
||||
s = &SessionInfo{ConnId: connId}
|
||||
} else {
|
||||
s = x.(*SessionInfo)
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), "SessionInfo", s)
|
||||
|
||||
if r.Method == MethodRDGOUT {
|
||||
if r.Header.Get("Connection") != "upgrade" && r.Header.Get("Upgrade") != "websocket" {
|
||||
g.handleLegacyProtocol(w, r.WithContext(ctx), s)
|
||||
return
|
||||
}
|
||||
r.Method = "GET" // force
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("Cannot upgrade falling back to old protocol: %s", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
g.handleWebsocketProtocol(ctx, conn, s)
|
||||
} else if r.Method == MethodRDGIN {
|
||||
g.handleLegacyProtocol(w, r.WithContext(ctx), s)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) handleWebsocketProtocol(ctx context.Context, c *websocket.Conn, s *SessionInfo) {
|
||||
websocketConnections.Inc()
|
||||
defer websocketConnections.Dec()
|
||||
|
||||
inout, _ := transport.NewWS(c)
|
||||
s.TransportOut = inout
|
||||
s.TransportIn = inout
|
||||
handler := NewServer(s, g.ServerConf)
|
||||
handler.Process(ctx)
|
||||
}
|
||||
|
||||
// The legacy protocol (no websockets) uses an RDG_IN_DATA for client -> server
|
||||
// and RDG_OUT_DATA for server -> client data. The handshakeRequest procedure is a bit different
|
||||
// to ensure the connections do not get cached or terminated by a proxy prematurely.
|
||||
func (g *Gateway) handleLegacyProtocol(w http.ResponseWriter, r *http.Request, s *SessionInfo) {
|
||||
log.Printf("Session %s, %t, %t", s.ConnId, s.TransportOut != nil, s.TransportIn != nil)
|
||||
|
||||
if r.Method == MethodRDGOUT {
|
||||
out, err := transport.NewLegacy(w)
|
||||
if err != nil {
|
||||
log.Printf("cannot hijack connection to support RDG OUT data channel: %s", err)
|
||||
return
|
||||
}
|
||||
log.Printf("Opening RDGOUT for client %s", common.GetClientIp(r.Context()))
|
||||
|
||||
s.TransportOut = out
|
||||
out.SendAccept(true)
|
||||
|
||||
c.Set(s.ConnId, s, cache.DefaultExpiration)
|
||||
} else if r.Method == MethodRDGIN {
|
||||
legacyConnections.Inc()
|
||||
defer legacyConnections.Dec()
|
||||
|
||||
in, err := transport.NewLegacy(w)
|
||||
if err != nil {
|
||||
log.Printf("cannot hijack connection to support RDG IN data channel: %s", err)
|
||||
return
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
if s.TransportIn == nil {
|
||||
s.TransportIn = in
|
||||
c.Set(s.ConnId, s, cache.DefaultExpiration)
|
||||
|
||||
log.Printf("Opening RDGIN for client %s", common.GetClientIp(r.Context()))
|
||||
in.SendAccept(false)
|
||||
|
||||
// read some initial data
|
||||
in.Drain()
|
||||
|
||||
log.Printf("Legacy handshakeRequest done for client %s", common.GetClientIp(r.Context()))
|
||||
handler := NewServer(s, g.ServerConf)
|
||||
handler.Process(r.Context())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,338 +0,0 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"github.com/bolkedebruin/rdpgw/common"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type VerifyTunnelCreate func(context.Context, string) (bool, error)
|
||||
type VerifyTunnelAuthFunc func(context.Context, string) (bool, error)
|
||||
type VerifyServerFunc func(context.Context, string) (bool, error)
|
||||
|
||||
type Server struct {
|
||||
Session *SessionInfo
|
||||
VerifyTunnelCreate VerifyTunnelCreate
|
||||
VerifyTunnelAuthFunc VerifyTunnelAuthFunc
|
||||
VerifyServerFunc VerifyServerFunc
|
||||
RedirectFlags int
|
||||
IdleTimeout int
|
||||
SmartCardAuth bool
|
||||
TokenAuth bool
|
||||
ClientName string
|
||||
Remote net.Conn
|
||||
State int
|
||||
}
|
||||
|
||||
type ServerConf struct {
|
||||
VerifyTunnelCreate VerifyTunnelCreate
|
||||
VerifyTunnelAuthFunc VerifyTunnelAuthFunc
|
||||
VerifyServerFunc VerifyServerFunc
|
||||
RedirectFlags RedirectFlags
|
||||
IdleTimeout int
|
||||
SmartCardAuth bool
|
||||
TokenAuth bool
|
||||
}
|
||||
|
||||
func NewServer(s *SessionInfo, conf *ServerConf) *Server {
|
||||
h := &Server{
|
||||
State: SERVER_STATE_INITIAL,
|
||||
Session: s,
|
||||
RedirectFlags: makeRedirectFlags(conf.RedirectFlags),
|
||||
IdleTimeout: conf.IdleTimeout,
|
||||
SmartCardAuth: conf.SmartCardAuth,
|
||||
TokenAuth: conf.TokenAuth,
|
||||
VerifyTunnelCreate: conf.VerifyTunnelCreate,
|
||||
VerifyServerFunc: conf.VerifyServerFunc,
|
||||
VerifyTunnelAuthFunc: conf.VerifyTunnelAuthFunc,
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
const tunnelId = 10
|
||||
|
||||
func (s *Server) Process(ctx context.Context) error {
|
||||
for {
|
||||
pt, sz, pkt, err := readMessage(s.Session.TransportIn)
|
||||
if err != nil {
|
||||
log.Printf("Cannot read message from stream %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
switch pt {
|
||||
case PKT_TYPE_HANDSHAKE_REQUEST:
|
||||
log.Printf("Client handshakeRequest from %s", common.GetClientIp(ctx))
|
||||
if s.State != SERVER_STATE_INITIAL {
|
||||
log.Printf("Handshake attempted while in wrong state %d != %d", s.State, SERVER_STATE_INITIAL)
|
||||
return errors.New("wrong state")
|
||||
}
|
||||
major, minor, _, _ := s.handshakeRequest(pkt) // todo check if auth matches what the handler can do
|
||||
msg := s.handshakeResponse(major, minor)
|
||||
s.Session.TransportOut.WritePacket(msg)
|
||||
s.State = SERVER_STATE_HANDSHAKE
|
||||
case PKT_TYPE_TUNNEL_CREATE:
|
||||
log.Printf("Tunnel create")
|
||||
if s.State != SERVER_STATE_HANDSHAKE {
|
||||
log.Printf("Tunnel create attempted while in wrong state %d != %d",
|
||||
s.State, SERVER_STATE_HANDSHAKE)
|
||||
return errors.New("wrong state")
|
||||
}
|
||||
_, cookie := s.tunnelRequest(pkt)
|
||||
if s.VerifyTunnelCreate != nil {
|
||||
if ok, _ := s.VerifyTunnelCreate(ctx, cookie); !ok {
|
||||
log.Printf("Invalid PAA cookie received from client %s", common.GetClientIp(ctx))
|
||||
return errors.New("invalid PAA cookie")
|
||||
}
|
||||
}
|
||||
msg := s.tunnelResponse()
|
||||
s.Session.TransportOut.WritePacket(msg)
|
||||
s.State = SERVER_STATE_TUNNEL_CREATE
|
||||
case PKT_TYPE_TUNNEL_AUTH:
|
||||
log.Printf("Tunnel auth")
|
||||
if s.State != SERVER_STATE_TUNNEL_CREATE {
|
||||
log.Printf("Tunnel auth attempted while in wrong state %d != %d",
|
||||
s.State, SERVER_STATE_TUNNEL_CREATE)
|
||||
return errors.New("wrong state")
|
||||
}
|
||||
client := s.tunnelAuthRequest(pkt)
|
||||
if s.VerifyTunnelAuthFunc != nil {
|
||||
if ok, _ := s.VerifyTunnelAuthFunc(ctx, client); !ok {
|
||||
log.Printf("Invalid client name: %s", client)
|
||||
return errors.New("invalid client name")
|
||||
}
|
||||
}
|
||||
msg := s.tunnelAuthResponse()
|
||||
s.Session.TransportOut.WritePacket(msg)
|
||||
s.State = SERVER_STATE_TUNNEL_AUTHORIZE
|
||||
case PKT_TYPE_CHANNEL_CREATE:
|
||||
log.Printf("Channel create")
|
||||
if s.State != SERVER_STATE_TUNNEL_AUTHORIZE {
|
||||
log.Printf("Channel create attempted while in wrong state %d != %d",
|
||||
s.State, SERVER_STATE_TUNNEL_AUTHORIZE)
|
||||
return errors.New("wrong state")
|
||||
}
|
||||
server, port := s.channelRequest(pkt)
|
||||
host := net.JoinHostPort(server, strconv.Itoa(int(port)))
|
||||
if s.VerifyServerFunc != nil {
|
||||
if ok, _ := s.VerifyServerFunc(ctx, host); !ok {
|
||||
log.Printf("Not allowed to connect to %s by policy handler", host)
|
||||
return errors.New("denied by security policy")
|
||||
}
|
||||
}
|
||||
log.Printf("Establishing connection to RDP server: %s", host)
|
||||
s.Remote, err = net.DialTimeout("tcp", host, time.Second*15)
|
||||
if err != nil {
|
||||
log.Printf("Error connecting to %s, %s", host, err)
|
||||
return err
|
||||
}
|
||||
log.Printf("Connection established")
|
||||
msg := s.channelResponse()
|
||||
s.Session.TransportOut.WritePacket(msg)
|
||||
|
||||
// Make sure to start the flow from the RDP server first otherwise connections
|
||||
// might hang eventually
|
||||
go forward(s.Remote, s.Session.TransportOut)
|
||||
s.State = SERVER_STATE_CHANNEL_CREATE
|
||||
case PKT_TYPE_DATA:
|
||||
if s.State < SERVER_STATE_CHANNEL_CREATE {
|
||||
log.Printf("Data received while in wrong state %d != %d", s.State, SERVER_STATE_CHANNEL_CREATE)
|
||||
return errors.New("wrong state")
|
||||
}
|
||||
s.State = SERVER_STATE_OPENED
|
||||
receive(pkt, s.Remote)
|
||||
case PKT_TYPE_KEEPALIVE:
|
||||
// keepalives can be received while the channel is not open yet
|
||||
if s.State < SERVER_STATE_CHANNEL_CREATE {
|
||||
log.Printf("Keepalive received while in wrong state %d != %d", s.State, SERVER_STATE_CHANNEL_CREATE)
|
||||
return errors.New("wrong state")
|
||||
}
|
||||
|
||||
// avoid concurrency issues
|
||||
// p.TransportIn.Write(createPacket(PKT_TYPE_KEEPALIVE, []byte{}))
|
||||
case PKT_TYPE_CLOSE_CHANNEL:
|
||||
log.Printf("Close channel")
|
||||
if s.State != SERVER_STATE_OPENED {
|
||||
log.Printf("Channel closed while in wrong state %d != %d", s.State, SERVER_STATE_OPENED)
|
||||
return errors.New("wrong state")
|
||||
}
|
||||
s.Session.TransportIn.Close()
|
||||
s.Session.TransportOut.Close()
|
||||
s.State = SERVER_STATE_CLOSED
|
||||
default:
|
||||
log.Printf("Unknown packet (size %d): %x", sz, pkt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Creates a packet the is a response to a handshakeRequest request
|
||||
// HTTP_EXTENDED_AUTH_SSPI_NTLM is not supported in Linux
|
||||
// but could be in Windows. However the NTLM protocol is insecure
|
||||
func (s *Server) handshakeResponse(major byte, minor byte) []byte {
|
||||
var caps uint16
|
||||
if s.SmartCardAuth {
|
||||
caps = caps | HTTP_EXTENDED_AUTH_SC
|
||||
}
|
||||
if s.TokenAuth {
|
||||
caps = caps | HTTP_EXTENDED_AUTH_PAA
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
binary.Write(buf, binary.LittleEndian, uint32(0)) // error_code
|
||||
buf.Write([]byte{major, minor})
|
||||
binary.Write(buf, binary.LittleEndian, uint16(0)) // server version
|
||||
binary.Write(buf, binary.LittleEndian, uint16(caps)) // extended auth
|
||||
|
||||
return createPacket(PKT_TYPE_HANDSHAKE_RESPONSE, buf.Bytes())
|
||||
}
|
||||
|
||||
func (s *Server) handshakeRequest(data []byte) (major byte, minor byte, version uint16, extAuth uint16) {
|
||||
r := bytes.NewReader(data)
|
||||
binary.Read(r, binary.LittleEndian, &major)
|
||||
binary.Read(r, binary.LittleEndian, &minor)
|
||||
binary.Read(r, binary.LittleEndian, &version)
|
||||
binary.Read(r, binary.LittleEndian, &extAuth)
|
||||
|
||||
log.Printf("major: %d, minor: %d, version: %d, ext auth: %d", major, minor, version, extAuth)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) tunnelRequest(data []byte) (caps uint32, cookie string) {
|
||||
var fields uint16
|
||||
|
||||
r := bytes.NewReader(data)
|
||||
|
||||
binary.Read(r, binary.LittleEndian, &caps)
|
||||
binary.Read(r, binary.LittleEndian, &fields)
|
||||
r.Seek(2, io.SeekCurrent)
|
||||
|
||||
if fields == HTTP_TUNNEL_PACKET_FIELD_PAA_COOKIE {
|
||||
var size uint16
|
||||
binary.Read(r, binary.LittleEndian, &size)
|
||||
cookieB := make([]byte, size)
|
||||
r.Read(cookieB)
|
||||
cookie, _ = DecodeUTF16(cookieB)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) tunnelResponse() []byte {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
binary.Write(buf, binary.LittleEndian, uint16(0)) // server version
|
||||
binary.Write(buf, binary.LittleEndian, uint32(0)) // error code
|
||||
binary.Write(buf, binary.LittleEndian, uint16(HTTP_TUNNEL_RESPONSE_FIELD_TUNNEL_ID|HTTP_TUNNEL_RESPONSE_FIELD_CAPS)) // fields present
|
||||
binary.Write(buf, binary.LittleEndian, uint16(0)) // reserved
|
||||
|
||||
// tunnel id (when is it used?)
|
||||
binary.Write(buf, binary.LittleEndian, uint32(tunnelId))
|
||||
|
||||
binary.Write(buf, binary.LittleEndian, uint32(HTTP_CAPABILITY_IDLE_TIMEOUT))
|
||||
|
||||
return createPacket(PKT_TYPE_TUNNEL_RESPONSE, buf.Bytes())
|
||||
}
|
||||
|
||||
func (s *Server) tunnelAuthRequest(data []byte) string {
|
||||
buf := bytes.NewReader(data)
|
||||
|
||||
var size uint16
|
||||
binary.Read(buf, binary.LittleEndian, &size)
|
||||
clData := make([]byte, size)
|
||||
binary.Read(buf, binary.LittleEndian, &clData)
|
||||
clientName, _ := DecodeUTF16(clData)
|
||||
|
||||
return clientName
|
||||
}
|
||||
|
||||
func (s *Server) tunnelAuthResponse() []byte {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
binary.Write(buf, binary.LittleEndian, uint32(0)) // error code
|
||||
binary.Write(buf, binary.LittleEndian, uint16(HTTP_TUNNEL_AUTH_RESPONSE_FIELD_REDIR_FLAGS|HTTP_TUNNEL_AUTH_RESPONSE_FIELD_IDLE_TIMEOUT)) // fields present
|
||||
binary.Write(buf, binary.LittleEndian, uint16(0)) // reserved
|
||||
|
||||
// idle timeout
|
||||
if s.IdleTimeout < 0 {
|
||||
s.IdleTimeout = 0
|
||||
}
|
||||
|
||||
binary.Write(buf, binary.LittleEndian, uint32(s.RedirectFlags)) // redir flags
|
||||
binary.Write(buf, binary.LittleEndian, uint32(s.IdleTimeout)) // timeout in minutes
|
||||
|
||||
return createPacket(PKT_TYPE_TUNNEL_AUTH_RESPONSE, buf.Bytes())
|
||||
}
|
||||
|
||||
func (s *Server) channelRequest(data []byte) (server string, port uint16) {
|
||||
buf := bytes.NewReader(data)
|
||||
|
||||
var resourcesSize byte
|
||||
var alternative byte
|
||||
var protocol uint16
|
||||
var nameSize uint16
|
||||
|
||||
binary.Read(buf, binary.LittleEndian, &resourcesSize)
|
||||
binary.Read(buf, binary.LittleEndian, &alternative)
|
||||
binary.Read(buf, binary.LittleEndian, &port)
|
||||
binary.Read(buf, binary.LittleEndian, &protocol)
|
||||
binary.Read(buf, binary.LittleEndian, &nameSize)
|
||||
|
||||
nameData := make([]byte, nameSize)
|
||||
binary.Read(buf, binary.LittleEndian, &nameData)
|
||||
|
||||
server, _ = DecodeUTF16(nameData)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) channelResponse() []byte {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
binary.Write(buf, binary.LittleEndian, uint32(0)) // error code
|
||||
binary.Write(buf, binary.LittleEndian, uint16(HTTP_CHANNEL_RESPONSE_FIELD_CHANNELID)) // fields present
|
||||
binary.Write(buf, binary.LittleEndian, uint16(0)) // reserved
|
||||
|
||||
// channel id is required for Windows clients
|
||||
binary.Write(buf, binary.LittleEndian, uint32(1)) // channel id
|
||||
|
||||
// optional fields
|
||||
// channel id uint32 (4)
|
||||
// udp port uint16 (2)
|
||||
// udp auth cookie 1 byte for side channel
|
||||
// length uint16
|
||||
|
||||
return createPacket(PKT_TYPE_CHANNEL_RESPONSE, buf.Bytes())
|
||||
}
|
||||
|
||||
func makeRedirectFlags(flags RedirectFlags) int {
|
||||
var redir = 0
|
||||
|
||||
if flags.DisableAll {
|
||||
return HTTP_TUNNEL_REDIR_DISABLE_ALL
|
||||
}
|
||||
if flags.EnableAll {
|
||||
return HTTP_TUNNEL_REDIR_ENABLE_ALL
|
||||
}
|
||||
|
||||
if !flags.Port {
|
||||
redir = redir | HTTP_TUNNEL_REDIR_DISABLE_PORT
|
||||
}
|
||||
if !flags.Clipboard {
|
||||
redir = redir | HTTP_TUNNEL_REDIR_DISABLE_CLIPBOARD
|
||||
}
|
||||
if !flags.Drive {
|
||||
redir = redir | HTTP_TUNNEL_REDIR_DISABLE_DRIVE
|
||||
}
|
||||
if !flags.Pnp {
|
||||
redir = redir | HTTP_TUNNEL_REDIR_DISABLE_PNP
|
||||
}
|
||||
if !flags.Printer {
|
||||
redir = redir | HTTP_TUNNEL_REDIR_DISABLE_PRINTER
|
||||
}
|
||||
return redir
|
||||
}
|
||||
240
security/jwt.go
240
security/jwt.go
@ -1,240 +0,0 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/bolkedebruin/rdpgw/common"
|
||||
"github.com/bolkedebruin/rdpgw/protocol"
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/square/go-jose/v3"
|
||||
"github.com/square/go-jose/v3/jwt"
|
||||
"golang.org/x/oauth2"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
SigningKey []byte
|
||||
EncryptionKey []byte
|
||||
UserSigningKey []byte
|
||||
UserEncryptionKey []byte
|
||||
OIDCProvider *oidc.Provider
|
||||
Oauth2Config oauth2.Config
|
||||
)
|
||||
|
||||
var ExpiryTime time.Duration = 5
|
||||
var VerifyClientIP bool = true
|
||||
|
||||
type customClaims struct {
|
||||
RemoteServer string `json:"remoteServer"`
|
||||
ClientIP string `json:"clientIp"`
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
|
||||
func VerifyPAAToken(ctx context.Context, tokenString string) (bool, error) {
|
||||
token, err := jwt.ParseSigned(tokenString)
|
||||
|
||||
// check if the signing algo matches what we expect
|
||||
for _, header := range token.Headers {
|
||||
if header.Algorithm != string(jose.HS256) {
|
||||
return false, fmt.Errorf("unexpected signing method: %v", header.Algorithm)
|
||||
}
|
||||
}
|
||||
|
||||
standard := jwt.Claims{}
|
||||
custom := customClaims{}
|
||||
|
||||
// Claims automagically checks the signature...
|
||||
err = token.Claims(SigningKey, &standard, &custom)
|
||||
if err != nil {
|
||||
log.Printf("token signature validation failed due to %s", err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
// ...but doesn't check the expiry claim :/
|
||||
err = standard.Validate(jwt.Expected{
|
||||
Issuer: "rdpgw",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("token validation failed due to %s", err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
// validate the access token
|
||||
tokenSource := Oauth2Config.TokenSource(ctx, &oauth2.Token{AccessToken: custom.AccessToken})
|
||||
_, err = OIDCProvider.UserInfo(ctx, tokenSource)
|
||||
if err != nil {
|
||||
log.Printf("Cannot get user info for access token: %s", err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
s := getSessionInfo(ctx)
|
||||
|
||||
s.RemoteServer = custom.RemoteServer
|
||||
s.ClientIp = custom.ClientIP
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func VerifyServerFunc(ctx context.Context, host string) (bool, error) {
|
||||
s := getSessionInfo(ctx)
|
||||
if s == nil {
|
||||
return false, errors.New("no valid session info found in context")
|
||||
}
|
||||
|
||||
if s.RemoteServer != host {
|
||||
log.Printf("Client specified host %s does not match token host %s", host, s.RemoteServer)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if VerifyClientIP && s.ClientIp != common.GetClientIp(ctx) {
|
||||
log.Printf("Current client ip address %s does not match token client ip %s",
|
||||
common.GetClientIp(ctx), s.ClientIp)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func GeneratePAAToken(ctx context.Context, username string, server string) (string, error) {
|
||||
if len(SigningKey) < 32 {
|
||||
return "", errors.New("token signing key not long enough or not specified")
|
||||
}
|
||||
sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS256, Key: SigningKey}, nil)
|
||||
if err != nil {
|
||||
log.Printf("Cannot obtain signer %s", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
standard := jwt.Claims{
|
||||
Issuer: "rdpgw",
|
||||
Expiry: jwt.NewNumericDate(time.Now().Add(time.Minute * 5)),
|
||||
Subject: username,
|
||||
}
|
||||
|
||||
private := customClaims{
|
||||
RemoteServer: server,
|
||||
ClientIP: common.GetClientIp(ctx),
|
||||
AccessToken: common.GetAccessToken(ctx),
|
||||
}
|
||||
|
||||
if token, err := jwt.Signed(sig).Claims(standard).Claims(private).CompactSerialize(); err != nil {
|
||||
log.Printf("Cannot sign PAA token %s", err)
|
||||
return "", err
|
||||
} else {
|
||||
return token, nil
|
||||
}
|
||||
}
|
||||
|
||||
func GenerateUserToken(ctx context.Context, userName string) (string, error) {
|
||||
if len(UserEncryptionKey) < 32 {
|
||||
return "", errors.New("user token encryption key not long enough or not specified")
|
||||
}
|
||||
|
||||
claims := jwt.Claims{
|
||||
Subject: userName,
|
||||
Expiry: jwt.NewNumericDate(time.Now().Add(time.Minute * 5)),
|
||||
Issuer: "rdpgw",
|
||||
}
|
||||
|
||||
enc, err := jose.NewEncrypter(
|
||||
jose.A128CBC_HS256,
|
||||
jose.Recipient{Algorithm: jose.DIRECT, Key: UserEncryptionKey},
|
||||
(&jose.EncrypterOptions{Compression: jose.DEFLATE}).WithContentType("JWT"),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Cannot encrypt user token due to %s", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
// this makes the token bigger and we deal with a limited space of 511 characters
|
||||
// sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS256, Key: SigningKey}, nil)
|
||||
// token, err := jwt.SignedAndEncrypted(sig, enc).Claims(claims).CompactSerialize()
|
||||
token, err := jwt.Encrypted(enc).Claims(claims).CompactSerialize()
|
||||
return token, err
|
||||
}
|
||||
|
||||
func UserInfo(ctx context.Context, token string) (jwt.Claims, error) {
|
||||
standard := jwt.Claims{}
|
||||
if len(UserEncryptionKey) > 0 && len(UserSigningKey) > 0 {
|
||||
enc, err := jwt.ParseSignedAndEncrypted(token)
|
||||
if err != nil {
|
||||
log.Printf("Cannot get token %s", err)
|
||||
return standard, errors.New("cannot get token")
|
||||
}
|
||||
token, err := enc.Decrypt(UserEncryptionKey)
|
||||
if err != nil {
|
||||
log.Printf("Cannot decrypt token %s", err)
|
||||
return standard, errors.New("cannot decrypt token")
|
||||
}
|
||||
if _, err := verifyAlg(token.Headers, string(jose.HS256)); err != nil {
|
||||
log.Printf("signature validation failure: %s", err)
|
||||
return standard, errors.New("signature validation failure")
|
||||
}
|
||||
if err = token.Claims(UserSigningKey, &standard); err != nil {
|
||||
log.Printf("cannot verify signature %s", err)
|
||||
return standard, errors.New("cannot verify signature")
|
||||
}
|
||||
} else if len(UserSigningKey) == 0 {
|
||||
token, err := jwt.ParseEncrypted(token)
|
||||
if err != nil {
|
||||
log.Printf("Cannot get token %s", err)
|
||||
return standard, errors.New("cannot get token")
|
||||
}
|
||||
err = token.Claims(UserEncryptionKey, &standard)
|
||||
if err != nil {
|
||||
log.Printf("Cannot decrypt token %s", err)
|
||||
return standard, errors.New("cannot decrypt token")
|
||||
}
|
||||
} else {
|
||||
token, err := jwt.ParseSigned(token)
|
||||
if err != nil {
|
||||
log.Printf("Cannot get token %s", err)
|
||||
return standard, errors.New("cannot get token")
|
||||
}
|
||||
if _, err := verifyAlg(token.Headers, string(jose.HS256)); err != nil {
|
||||
log.Printf("signature validation failure: %s", err)
|
||||
return standard, errors.New("signature validation failure")
|
||||
}
|
||||
err = token.Claims(UserSigningKey, &standard)
|
||||
if err = token.Claims(UserSigningKey, &standard); err != nil {
|
||||
log.Printf("cannot verify signature %s", err)
|
||||
return standard, errors.New("cannot verify signature")
|
||||
}
|
||||
}
|
||||
|
||||
// go-jose doesnt verify the expiry
|
||||
err := standard.Validate(jwt.Expected{
|
||||
Issuer: "rdpgw",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("token validation failed due to %s", err)
|
||||
return standard, fmt.Errorf("token validation failed due to %s", err)
|
||||
}
|
||||
|
||||
return standard, nil
|
||||
}
|
||||
|
||||
func getSessionInfo(ctx context.Context) *protocol.SessionInfo {
|
||||
s, ok := ctx.Value("SessionInfo").(*protocol.SessionInfo)
|
||||
if !ok {
|
||||
log.Printf("cannot get session info from context")
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func verifyAlg(headers []jose.Header, alg string) (bool, error) {
|
||||
for _, header := range headers {
|
||||
if header.Algorithm != alg {
|
||||
return false, fmt.Errorf("invalid signing method %s", header.Algorithm)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
401
shared/auth/auth.pb.go
Normal file
401
shared/auth/auth.pb.go
Normal file
@ -0,0 +1,401 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.25.0-devel
|
||||
// protoc v3.14.0
|
||||
// source: auth.proto
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type UserPass struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"`
|
||||
Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"`
|
||||
}
|
||||
|
||||
func (x *UserPass) Reset() {
|
||||
*x = UserPass{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_auth_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *UserPass) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*UserPass) ProtoMessage() {}
|
||||
|
||||
func (x *UserPass) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_auth_proto_msgTypes[0]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use UserPass.ProtoReflect.Descriptor instead.
|
||||
func (*UserPass) Descriptor() ([]byte, []int) {
|
||||
return file_auth_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *UserPass) GetUsername() string {
|
||||
if x != nil {
|
||||
return x.Username
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *UserPass) GetPassword() string {
|
||||
if x != nil {
|
||||
return x.Password
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type AuthResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Authenticated bool `protobuf:"varint,1,opt,name=authenticated,proto3" json:"authenticated,omitempty"`
|
||||
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (x *AuthResponse) Reset() {
|
||||
*x = AuthResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_auth_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *AuthResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*AuthResponse) ProtoMessage() {}
|
||||
|
||||
func (x *AuthResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_auth_proto_msgTypes[1]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use AuthResponse.ProtoReflect.Descriptor instead.
|
||||
func (*AuthResponse) Descriptor() ([]byte, []int) {
|
||||
return file_auth_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *AuthResponse) GetAuthenticated() bool {
|
||||
if x != nil {
|
||||
return x.Authenticated
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *AuthResponse) GetError() string {
|
||||
if x != nil {
|
||||
return x.Error
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type NtlmRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Session string `protobuf:"bytes,1,opt,name=session,proto3" json:"session,omitempty"`
|
||||
NtlmMessage string `protobuf:"bytes,2,opt,name=ntlmMessage,proto3" json:"ntlmMessage,omitempty"`
|
||||
}
|
||||
|
||||
func (x *NtlmRequest) Reset() {
|
||||
*x = NtlmRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_auth_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *NtlmRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*NtlmRequest) ProtoMessage() {}
|
||||
|
||||
func (x *NtlmRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_auth_proto_msgTypes[2]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use NtlmRequest.ProtoReflect.Descriptor instead.
|
||||
func (*NtlmRequest) Descriptor() ([]byte, []int) {
|
||||
return file_auth_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
func (x *NtlmRequest) GetSession() string {
|
||||
if x != nil {
|
||||
return x.Session
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *NtlmRequest) GetNtlmMessage() string {
|
||||
if x != nil {
|
||||
return x.NtlmMessage
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type NtlmResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Authenticated bool `protobuf:"varint,1,opt,name=authenticated,proto3" json:"authenticated,omitempty"`
|
||||
Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"`
|
||||
NtlmMessage string `protobuf:"bytes,3,opt,name=ntlmMessage,proto3" json:"ntlmMessage,omitempty"`
|
||||
Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (x *NtlmResponse) Reset() {
|
||||
*x = NtlmResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_auth_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *NtlmResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*NtlmResponse) ProtoMessage() {}
|
||||
|
||||
func (x *NtlmResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_auth_proto_msgTypes[3]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use NtlmResponse.ProtoReflect.Descriptor instead.
|
||||
func (*NtlmResponse) Descriptor() ([]byte, []int) {
|
||||
return file_auth_proto_rawDescGZIP(), []int{3}
|
||||
}
|
||||
|
||||
func (x *NtlmResponse) GetAuthenticated() bool {
|
||||
if x != nil {
|
||||
return x.Authenticated
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *NtlmResponse) GetUsername() string {
|
||||
if x != nil {
|
||||
return x.Username
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *NtlmResponse) GetNtlmMessage() string {
|
||||
if x != nil {
|
||||
return x.NtlmMessage
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *NtlmResponse) GetError() string {
|
||||
if x != nil {
|
||||
return x.Error
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var File_auth_proto protoreflect.FileDescriptor
|
||||
|
||||
var file_auth_proto_rawDesc = []byte{
|
||||
0x0a, 0x0a, 0x61, 0x75, 0x74, 0x68, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x04, 0x61, 0x75,
|
||||
0x74, 0x68, 0x22, 0x42, 0x0a, 0x08, 0x55, 0x73, 0x65, 0x72, 0x50, 0x61, 0x73, 0x73, 0x12, 0x1a,
|
||||
0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61,
|
||||
0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61,
|
||||
0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x4a, 0x0a, 0x0c, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65,
|
||||
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x24, 0x0a, 0x0d, 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e,
|
||||
0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x61,
|
||||
0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x64, 0x12, 0x14, 0x0a, 0x05,
|
||||
0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72,
|
||||
0x6f, 0x72, 0x22, 0x49, 0x0a, 0x0b, 0x4e, 0x74, 0x6c, 0x6d, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
|
||||
0x74, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x6e,
|
||||
0x74, 0x6c, 0x6d, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x0b, 0x6e, 0x74, 0x6c, 0x6d, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x88, 0x01,
|
||||
0x0a, 0x0c, 0x4e, 0x74, 0x6c, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x24,
|
||||
0x0a, 0x0d, 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x64, 0x18,
|
||||
0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63,
|
||||
0x61, 0x74, 0x65, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65,
|
||||
0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65,
|
||||
0x12, 0x20, 0x0a, 0x0b, 0x6e, 0x74, 0x6c, 0x6d, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18,
|
||||
0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6e, 0x74, 0x6c, 0x6d, 0x4d, 0x65, 0x73, 0x73, 0x61,
|
||||
0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x32, 0x75, 0x0a, 0x0c, 0x41, 0x75, 0x74, 0x68,
|
||||
0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x34, 0x0a, 0x0c, 0x41, 0x75, 0x74, 0x68,
|
||||
0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x0e, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x2e,
|
||||
0x55, 0x73, 0x65, 0x72, 0x50, 0x61, 0x73, 0x73, 0x1a, 0x12, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x2e,
|
||||
0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x2f,
|
||||
0x0a, 0x04, 0x4e, 0x54, 0x4c, 0x4d, 0x12, 0x11, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x2e, 0x4e, 0x74,
|
||||
0x6c, 0x6d, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x12, 0x2e, 0x61, 0x75, 0x74, 0x68,
|
||||
0x2e, 0x4e, 0x74, 0x6c, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42,
|
||||
0x08, 0x5a, 0x06, 0x2e, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f,
|
||||
0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
file_auth_proto_rawDescOnce sync.Once
|
||||
file_auth_proto_rawDescData = file_auth_proto_rawDesc
|
||||
)
|
||||
|
||||
func file_auth_proto_rawDescGZIP() []byte {
|
||||
file_auth_proto_rawDescOnce.Do(func() {
|
||||
file_auth_proto_rawDescData = protoimpl.X.CompressGZIP(file_auth_proto_rawDescData)
|
||||
})
|
||||
return file_auth_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
|
||||
var file_auth_proto_goTypes = []interface{}{
|
||||
(*UserPass)(nil), // 0: auth.UserPass
|
||||
(*AuthResponse)(nil), // 1: auth.AuthResponse
|
||||
(*NtlmRequest)(nil), // 2: auth.NtlmRequest
|
||||
(*NtlmResponse)(nil), // 3: auth.NtlmResponse
|
||||
}
|
||||
var file_auth_proto_depIdxs = []int32{
|
||||
0, // 0: auth.Authenticate.Authenticate:input_type -> auth.UserPass
|
||||
2, // 1: auth.Authenticate.NTLM:input_type -> auth.NtlmRequest
|
||||
1, // 2: auth.Authenticate.Authenticate:output_type -> auth.AuthResponse
|
||||
3, // 3: auth.Authenticate.NTLM:output_type -> auth.NtlmResponse
|
||||
2, // [2:4] is the sub-list for method output_type
|
||||
0, // [0:2] is the sub-list for method input_type
|
||||
0, // [0:0] is the sub-list for extension type_name
|
||||
0, // [0:0] is the sub-list for extension extendee
|
||||
0, // [0:0] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_auth_proto_init() }
|
||||
func file_auth_proto_init() {
|
||||
if File_auth_proto != nil {
|
||||
return
|
||||
}
|
||||
if !protoimpl.UnsafeEnabled {
|
||||
file_auth_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*UserPass); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_auth_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*AuthResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_auth_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*NtlmRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_auth_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*NtlmResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: file_auth_proto_rawDesc,
|
||||
NumEnums: 0,
|
||||
NumMessages: 4,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
GoTypes: file_auth_proto_goTypes,
|
||||
DependencyIndexes: file_auth_proto_depIdxs,
|
||||
MessageInfos: file_auth_proto_msgTypes,
|
||||
}.Build()
|
||||
File_auth_proto = out.File
|
||||
file_auth_proto_rawDesc = nil
|
||||
file_auth_proto_goTypes = nil
|
||||
file_auth_proto_depIdxs = nil
|
||||
}
|
||||
137
shared/auth/auth_grpc.pb.go
Normal file
137
shared/auth/auth_grpc.pb.go
Normal file
@ -0,0 +1,137 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
context "context"
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the grpc package it is being compiled against.
|
||||
// Requires gRPC-Go v1.32.0 or later.
|
||||
const _ = grpc.SupportPackageIsVersion7
|
||||
|
||||
// AuthenticateClient is the client API for Authenticate service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
type AuthenticateClient interface {
|
||||
Authenticate(ctx context.Context, in *UserPass, opts ...grpc.CallOption) (*AuthResponse, error)
|
||||
NTLM(ctx context.Context, in *NtlmRequest, opts ...grpc.CallOption) (*NtlmResponse, error)
|
||||
}
|
||||
|
||||
type authenticateClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewAuthenticateClient(cc grpc.ClientConnInterface) AuthenticateClient {
|
||||
return &authenticateClient{cc}
|
||||
}
|
||||
|
||||
func (c *authenticateClient) Authenticate(ctx context.Context, in *UserPass, opts ...grpc.CallOption) (*AuthResponse, error) {
|
||||
out := new(AuthResponse)
|
||||
err := c.cc.Invoke(ctx, "/auth.Authenticate/Authenticate", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *authenticateClient) NTLM(ctx context.Context, in *NtlmRequest, opts ...grpc.CallOption) (*NtlmResponse, error) {
|
||||
out := new(NtlmResponse)
|
||||
err := c.cc.Invoke(ctx, "/auth.Authenticate/NTLM", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AuthenticateServer is the server API for Authenticate service.
|
||||
// All implementations must embed UnimplementedAuthenticateServer
|
||||
// for forward compatibility
|
||||
type AuthenticateServer interface {
|
||||
Authenticate(context.Context, *UserPass) (*AuthResponse, error)
|
||||
NTLM(context.Context, *NtlmRequest) (*NtlmResponse, error)
|
||||
mustEmbedUnimplementedAuthenticateServer()
|
||||
}
|
||||
|
||||
// UnimplementedAuthenticateServer must be embedded to have forward compatible implementations.
|
||||
type UnimplementedAuthenticateServer struct {
|
||||
}
|
||||
|
||||
func (UnimplementedAuthenticateServer) Authenticate(context.Context, *UserPass) (*AuthResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Authenticate not implemented")
|
||||
}
|
||||
func (UnimplementedAuthenticateServer) NTLM(context.Context, *NtlmRequest) (*NtlmResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method NTLM not implemented")
|
||||
}
|
||||
func (UnimplementedAuthenticateServer) mustEmbedUnimplementedAuthenticateServer() {}
|
||||
|
||||
// UnsafeAuthenticateServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to AuthenticateServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeAuthenticateServer interface {
|
||||
mustEmbedUnimplementedAuthenticateServer()
|
||||
}
|
||||
|
||||
func RegisterAuthenticateServer(s grpc.ServiceRegistrar, srv AuthenticateServer) {
|
||||
s.RegisterService(&Authenticate_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _Authenticate_Authenticate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(UserPass)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(AuthenticateServer).Authenticate(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/auth.Authenticate/Authenticate",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(AuthenticateServer).Authenticate(ctx, req.(*UserPass))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Authenticate_NTLM_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(NtlmRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(AuthenticateServer).NTLM(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/auth.Authenticate/NTLM",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(AuthenticateServer).NTLM(ctx, req.(*NtlmRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// Authenticate_ServiceDesc is the grpc.ServiceDesc for Authenticate service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var Authenticate_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "auth.Authenticate",
|
||||
HandlerType: (*AuthenticateServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "Authenticate",
|
||||
Handler: _Authenticate_Authenticate_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "NTLM",
|
||||
Handler: _Authenticate_NTLM_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "auth.proto",
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user