mirror of
https://github.com/bolkedebruin/rdpgw.git
synced 2026-02-10 18:07:44 +00:00
Merge branch 'bolkedebruin:master' into anysigned
This commit is contained in:
commit
c1c752ebfb
148
README.md
148
README.md
@ -28,7 +28,8 @@ 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.
|
||||
an OpenID Connect provider, Kerberos, a local PAM service, a local database, or extracted from HTTP headers
|
||||
provided by upstream proxy services.
|
||||
|
||||
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
|
||||
@ -61,7 +62,7 @@ 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.
|
||||
mechanisms such as OpenID Connect, Kerberos, PAM, NTLM, and header-based authentication for proxy integration.
|
||||
|
||||
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)
|
||||
@ -78,142 +79,28 @@ if you want.
|
||||
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
|
||||

|
||||
### OpenID 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
|
||||
```
|
||||
|
||||
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.
|
||||
For detailed OpenID Connect setup with providers like Keycloak, Azure AD, Google, and others, see the [OpenID Connect Authentication Documentation](docs/openid-authentication.md).
|
||||
|
||||
### 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.
|
||||
For detailed Kerberos setup including keytab generation, DNS requirements, and KDC proxy configuration, see the [Kerberos Authentication Documentation](docs/kerberos-authentication.md).
|
||||
|
||||
|
||||
### PAM / Local (Basic Auth)
|
||||

|
||||
### PAM/Local Authentication
|
||||
|
||||
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.
|
||||
For detailed PAM setup including LDAP integration, container deployment, and compatible clients, see the [PAM Authentication Documentation](docs/pam-authentication.md).
|
||||
|
||||
__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.
|
||||
### 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.
|
||||
For detailed NTLM setup including user management, security considerations, and deployment options, see the [NTLM Authentication Documentation](docs/ntlm-authentication.md).
|
||||
|
||||
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.
|
||||
### Header Authentication (Proxy Integration)
|
||||
|
||||
```plaintext
|
||||
auth required pam_unix.so
|
||||
account required pam_unix.so
|
||||
```
|
||||
RDPGW supports header-based authentication for integration with reverse proxy services (Azure App Proxy, Google IAP, AWS ALB, etc.) that handle authentication upstream and pass user identity via HTTP headers.
|
||||
|
||||
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.
|
||||
For detailed configuration and examples, see the [Header Authentication Documentation](docs/header-authentication.md).
|
||||
|
||||
## TLS
|
||||
|
||||
@ -424,6 +311,15 @@ otherwise the client will not connect at all (it won't send any packages to the
|
||||
|
||||
Finally, ``mstsc`` requires a valid certificate on the gateway.
|
||||
|
||||
Additionally, ``mstsc`` is more restrictive about SSL cipher suites compared to other RDP clients. When using a reverse proxy like nginx for TLS termination, you may need to configure specific cipher suites that ``mstsc`` supports. A working configuration for nginx ``ssl_ciphers`` is:
|
||||
```
|
||||
ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256
|
||||
```
|
||||
|
||||
``mstsc`` also requires server names rather than IP addresses for connections, despite Microsoft's documentation suggesting otherwise. When configuring hosts in the rdpgw configuration, ensure you use hostnames.
|
||||
|
||||
Furthermore, the ``mstsc`` client sends the hostname including the port number when establishing connections. To ensure proper host verification, configure your hosts in the rdpgw configuration file with the port numbers included (e.g., ``myserver:3389`` even for the default RDP port 3389).
|
||||
|
||||
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.
|
||||
|
||||
32
assets/connect.svg
Normal file
32
assets/connect.svg
Normal file
@ -0,0 +1,32 @@
|
||||
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Drop shadow -->
|
||||
<defs>
|
||||
<filter id="drop-shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="4" stdDeviation="8" flood-color="rgba(0,0,0,0.2)"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Main circle -->
|
||||
<circle cx="100" cy="100" r="90" fill="#D2572A" filter="url(#drop-shadow)"/>
|
||||
|
||||
<!-- White outline circle -->
|
||||
<circle cx="100" cy="100" r="85" fill="none" stroke="#F5F5F5" stroke-width="2"/>
|
||||
|
||||
<!-- Monitor screen (centered) -->
|
||||
<rect x="70" y="75" width="60" height="40" rx="2" ry="2" fill="none" stroke="white" stroke-width="4"/>
|
||||
|
||||
<!-- Monitor base -->
|
||||
<rect x="95" y="115" width="30" height="6" rx="1" ry="1" fill="none" stroke="white" stroke-width="4"/>
|
||||
|
||||
<!-- Monitor stand -->
|
||||
<rect x="105" y="121" width="10" height="10" fill="none" stroke="white" stroke-width="4"/>
|
||||
|
||||
<!-- Connection symbol circle background -->
|
||||
<circle cx="125" cy="85" r="16" fill="white"/>
|
||||
|
||||
<!-- Connection symbol -->
|
||||
<g stroke="#D2572A" stroke-width="2.5" fill="none" stroke-linecap="round">
|
||||
<!-- Signal waves -->
|
||||
<path d="M 115 85 Q 120 80, 125 85 Q 130 90, 135 85"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
10
assets/icon.svg
Normal file
10
assets/icon.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- White background circle -->
|
||||
<circle cx="50" cy="50" r="45" fill="white"/>
|
||||
|
||||
<!-- Black circular outline -->
|
||||
<circle cx="50" cy="50" r="45" fill="none" stroke="black" stroke-width="4"/>
|
||||
|
||||
<!-- Bold black R -->
|
||||
<text x="50" y="68" text-anchor="middle" font-family="Georgia, serif" font-size="58" font-weight="bold" fill="black" style="font-style: italic;">R</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 485 B |
@ -24,12 +24,14 @@ const (
|
||||
AuthenticationOpenId = "openid"
|
||||
AuthenticationBasic = "local"
|
||||
AuthenticationKerberos = "kerberos"
|
||||
AuthenticationHeader = "header"
|
||||
)
|
||||
|
||||
type Configuration struct {
|
||||
Server ServerConfig `koanf:"server"`
|
||||
OpenId OpenIDConfig `koanf:"openid"`
|
||||
Kerberos KerberosConfig `koanf:"kerberos"`
|
||||
Header HeaderConfig `koanf:"header"`
|
||||
Caps RDGCapsConfig `koanf:"caps"`
|
||||
Security SecurityConfig `koanf:"security"`
|
||||
Client ClientConfig `koanf:"client"`
|
||||
@ -65,6 +67,13 @@ type OpenIDConfig struct {
|
||||
ClientSecret string `koanf:"clientsecret"`
|
||||
}
|
||||
|
||||
type HeaderConfig struct {
|
||||
UserHeader string `koanf:"userheader"`
|
||||
UserIdHeader string `koanf:"useridheader"`
|
||||
EmailHeader string `koanf:"emailheader"`
|
||||
DisplayNameHeader string `koanf:"displaynameheader"`
|
||||
}
|
||||
|
||||
type RDGCapsConfig struct {
|
||||
SmartCardAuth bool `koanf:"smartcardauth"`
|
||||
TokenAuth bool `koanf:"tokenauth"`
|
||||
@ -181,6 +190,7 @@ func Load(configFile string) Configuration {
|
||||
koanfTag := koanf.UnmarshalConf{Tag: "koanf"}
|
||||
k.UnmarshalWithConf("Server", &Conf.Server, koanfTag)
|
||||
k.UnmarshalWithConf("OpenId", &Conf.OpenId, koanfTag)
|
||||
k.UnmarshalWithConf("Header", &Conf.Header, koanfTag)
|
||||
k.UnmarshalWithConf("Caps", &Conf.Caps, koanfTag)
|
||||
k.UnmarshalWithConf("Security", &Conf.Security, koanfTag)
|
||||
k.UnmarshalWithConf("Client", &Conf.Client, koanfTag)
|
||||
@ -233,6 +243,10 @@ func Load(configFile string) Configuration {
|
||||
log.Fatalf("kerberos is configured but no keytab was specified")
|
||||
}
|
||||
|
||||
if Conf.Server.HeaderEnabled() && Conf.Header.UserHeader == "" {
|
||||
log.Fatalf("header authentication is configured but no user header was specified")
|
||||
}
|
||||
|
||||
// prepend '//' if required for URL parsing
|
||||
if !strings.Contains(Conf.Server.GatewayAddress, "//") {
|
||||
Conf.Server.GatewayAddress = "//" + Conf.Server.GatewayAddress
|
||||
@ -257,6 +271,10 @@ func (s *ServerConfig) NtlmEnabled() bool {
|
||||
return s.matchAuth("ntlm")
|
||||
}
|
||||
|
||||
func (s *ServerConfig) HeaderEnabled() bool {
|
||||
return s.matchAuth("header")
|
||||
}
|
||||
|
||||
func (s *ServerConfig) matchAuth(needle string) bool {
|
||||
for _, q := range s.Authentication {
|
||||
if q == needle {
|
||||
|
||||
89
cmd/rdpgw/config/configuration_test.go
Normal file
89
cmd/rdpgw/config/configuration_test.go
Normal file
@ -0,0 +1,89 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHeaderEnabled(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
authentication []string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "header_enabled",
|
||||
authentication: []string{"header"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "header_with_others",
|
||||
authentication: []string{"openid", "header", "local"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "header_not_enabled",
|
||||
authentication: []string{"openid", "local"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "empty_authentication",
|
||||
authentication: []string{},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
config := &ServerConfig{
|
||||
Authentication: tc.authentication,
|
||||
}
|
||||
|
||||
result := config.HeaderEnabled()
|
||||
if result != tc.expected {
|
||||
t.Errorf("expected HeaderEnabled(): %v, got: %v", tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticationConstants(t *testing.T) {
|
||||
// Test that the header authentication constant is correct
|
||||
if AuthenticationHeader != "header" {
|
||||
t.Errorf("incorrect authentication header constant: %v", AuthenticationHeader)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderConfigValidation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
headerConf HeaderConfig
|
||||
shouldError bool
|
||||
}{
|
||||
{
|
||||
name: "valid_config",
|
||||
headerConf: HeaderConfig{
|
||||
UserHeader: "X-Forwarded-User",
|
||||
},
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "missing_user_header",
|
||||
headerConf: HeaderConfig{
|
||||
EmailHeader: "X-Forwarded-Email",
|
||||
},
|
||||
shouldError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Test the configuration struct
|
||||
if tc.headerConf.UserHeader == "" && !tc.shouldError {
|
||||
t.Error("expected user header to be set")
|
||||
}
|
||||
if tc.headerConf.UserHeader != "" && tc.shouldError {
|
||||
t.Error("expected configuration to be invalid")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -213,6 +213,9 @@ func main() {
|
||||
// for sso callbacks
|
||||
r.HandleFunc("/tokeninfo", web.TokenInfo)
|
||||
|
||||
// API routes
|
||||
api := r.PathPrefix("/api/v1").Subrouter()
|
||||
|
||||
// gateway endpoint
|
||||
rdp := r.PathPrefix(gatewayEndPoint).Subrouter()
|
||||
|
||||
@ -223,8 +226,50 @@ func main() {
|
||||
r.Handle("/connect", o.Authenticated(http.HandlerFunc(h.HandleDownload)))
|
||||
r.HandleFunc("/callback", o.HandleCallback)
|
||||
|
||||
// Web interface and API routes (authenticated)
|
||||
r.Handle("/", o.Authenticated(http.HandlerFunc(h.HandleWebInterface)))
|
||||
api.Handle("/hosts", o.Authenticated(http.HandlerFunc(h.HandleHostList)))
|
||||
api.Handle("/user", o.Authenticated(http.HandlerFunc(h.HandleUserInfo)))
|
||||
|
||||
// Static files (no authentication required)
|
||||
r.HandleFunc("/static/style.css", h.ServeStaticFile("style.css"))
|
||||
r.HandleFunc("/static/app.js", h.ServeStaticFile("app.js"))
|
||||
// Asset files (no authentication required)
|
||||
r.HandleFunc("/assets/connect.svg", h.ServeAssetFile("connect.svg"))
|
||||
r.HandleFunc("/assets/icon.svg", h.ServeAssetFile("icon.svg"))
|
||||
|
||||
// only enable un-auth endpoint for openid only config
|
||||
if !conf.Server.KerberosEnabled() && !conf.Server.BasicAuthEnabled() && !conf.Server.NtlmEnabled() {
|
||||
if !conf.Server.KerberosEnabled() && !conf.Server.BasicAuthEnabled() && !conf.Server.NtlmEnabled() && !conf.Server.HeaderEnabled() {
|
||||
rdp.Name("gw").HandlerFunc(gw.HandleGatewayProtocol)
|
||||
}
|
||||
}
|
||||
|
||||
// header auth (configurable proxy)
|
||||
if conf.Server.HeaderEnabled() {
|
||||
log.Printf("enabling header authentication with user header: %s", conf.Header.UserHeader)
|
||||
headerConfig := &web.HeaderConfig{
|
||||
UserHeader: conf.Header.UserHeader,
|
||||
UserIdHeader: conf.Header.UserIdHeader,
|
||||
EmailHeader: conf.Header.EmailHeader,
|
||||
DisplayNameHeader: conf.Header.DisplayNameHeader,
|
||||
}
|
||||
headerAuth := headerConfig.New()
|
||||
r.Handle("/connect", headerAuth.Authenticated(http.HandlerFunc(h.HandleDownload)))
|
||||
|
||||
// Web interface and API routes (authenticated)
|
||||
r.Handle("/", headerAuth.Authenticated(http.HandlerFunc(h.HandleWebInterface)))
|
||||
api.Handle("/hosts", headerAuth.Authenticated(http.HandlerFunc(h.HandleHostList)))
|
||||
api.Handle("/user", headerAuth.Authenticated(http.HandlerFunc(h.HandleUserInfo)))
|
||||
|
||||
// Static files (no authentication required)
|
||||
r.HandleFunc("/static/style.css", h.ServeStaticFile("style.css"))
|
||||
r.HandleFunc("/static/app.js", h.ServeStaticFile("app.js"))
|
||||
// Asset files (no authentication required)
|
||||
r.HandleFunc("/assets/connect.svg", h.ServeAssetFile("connect.svg"))
|
||||
r.HandleFunc("/assets/icon.svg", h.ServeAssetFile("icon.svg"))
|
||||
|
||||
// only enable un-auth endpoint for header only config
|
||||
if !conf.Server.KerberosEnabled() && !conf.Server.BasicAuthEnabled() && !conf.Server.NtlmEnabled() && !conf.Server.OpenIDEnabled() {
|
||||
rdp.Name("gw").HandlerFunc(gw.HandleGatewayProtocol)
|
||||
}
|
||||
}
|
||||
|
||||
95
cmd/rdpgw/templates/README.md
Normal file
95
cmd/rdpgw/templates/README.md
Normal file
@ -0,0 +1,95 @@
|
||||
# RDP Gateway Web Interface Templates
|
||||
|
||||
This directory contains the customizable web interface templates for RDP Gateway.
|
||||
|
||||
## Files
|
||||
|
||||
### `index.html`
|
||||
The main HTML template for the web interface. This file uses Go template syntax and can be customized to match your organization's branding.
|
||||
|
||||
**Template Variables Available:**
|
||||
- `{{.Title}}` - Page title
|
||||
- `{{.Logo}}` - Header logo text
|
||||
- `{{.PageTitle}}` - Main page heading
|
||||
- `{{.SelectServerMessage}}` - Default button text
|
||||
- `{{.PreparingMessage}}` - Loading message
|
||||
- `{{.AutoLaunchMessage}}` - Auto-launch notice text
|
||||
|
||||
### `style.css`
|
||||
The CSS stylesheet for the web interface. Modify this file to customize:
|
||||
- Colors and branding
|
||||
- Layout and spacing
|
||||
- Fonts and typography
|
||||
- Responsive behavior
|
||||
|
||||
### `app.js`
|
||||
The JavaScript file containing the web interface logic. This includes:
|
||||
- Server list loading and rendering
|
||||
- User authentication display
|
||||
- **Automatic RDP client launching** (multiple methods)
|
||||
- File download fallback
|
||||
- Progress animations
|
||||
|
||||
### `config-example.json`
|
||||
Example configuration structure showing available customization options. These values are set as defaults in the code but can be integrated with your main configuration system.
|
||||
|
||||
## Auto-Launch Functionality
|
||||
|
||||
The interface automatically attempts to launch RDP clients using **actual RDP file content**:
|
||||
|
||||
### How It Works:
|
||||
1. **Fetches RDP Content**: Gets the complete RDP file configuration from `/api/rdp-content`
|
||||
2. **Creates Data URL**: Converts RDP content to a downloadable blob
|
||||
3. **Platform-Specific Launch**:
|
||||
- **Windows**: Downloads .rdp file which auto-opens with mstsc
|
||||
- **macOS**: Downloads .rdp file which auto-opens with Microsoft Remote Desktop
|
||||
- **Universal**: Creates temporary download that browsers handle appropriately
|
||||
|
||||
### Technical Implementation:
|
||||
- **`/api/rdp-content`** endpoint generates actual RDP file content with proper tokens
|
||||
- **Data URLs** created from RDP content for browser download
|
||||
- **Automatic file association** triggers RDP client launch
|
||||
- **Graceful fallbacks** ensure users always get the RDP file
|
||||
|
||||
## Customization
|
||||
|
||||
To customize the interface:
|
||||
|
||||
1. **Copy this templates directory** to your preferred location
|
||||
2. **Set the templates path** in your RDP Gateway configuration
|
||||
3. **Edit the files** to match your branding requirements
|
||||
4. **Restart RDP Gateway** to load the new templates
|
||||
|
||||
If template files are missing, the system automatically falls back to embedded templates to ensure the interface remains functional.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
The web interface uses these authenticated API endpoints:
|
||||
|
||||
- **`/api/hosts`** - Returns available servers for the user (JSON)
|
||||
- **`/api/user`** - Returns current user information (JSON)
|
||||
- **`/api/rdp-content`** - Returns RDP file content as text for auto-launch
|
||||
- **`/connect`** - Downloads RDP file (traditional endpoint)
|
||||
|
||||
## Static File Serving
|
||||
|
||||
The following URLs serve static files:
|
||||
- `/static/style.css` - CSS stylesheet
|
||||
- `/static/app.js` - JavaScript application
|
||||
|
||||
These files are served without authentication requirements for better performance.
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
The interface supports:
|
||||
- Modern browsers (Chrome, Firefox, Safari, Edge)
|
||||
- Mobile responsive design
|
||||
- Protocol handlers for RDP client launching
|
||||
- Graceful fallbacks for unsupported features
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Template files are served from the server filesystem
|
||||
- Static files include cache headers for performance
|
||||
- User authentication is required for the main interface
|
||||
- API endpoints validate authentication before serving data
|
||||
263
cmd/rdpgw/templates/app.js
Normal file
263
cmd/rdpgw/templates/app.js
Normal file
@ -0,0 +1,263 @@
|
||||
// RDP Gateway Web Interface
|
||||
let userInfo = null;
|
||||
|
||||
// Theme handling - SVG logo works for both light and dark modes
|
||||
function updateLogo() {
|
||||
const logoImage = document.getElementById('logoImage');
|
||||
if (logoImage) {
|
||||
logoImage.src = '/assets/icon.svg';
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration
|
||||
const config = {
|
||||
progressAnimationDuration: 2000, // ms for progress bar animation
|
||||
};
|
||||
|
||||
// Get user initials for avatar
|
||||
function getUserInitials(name) {
|
||||
return name.split(' ').map(word => word.charAt(0)).slice(0, 2).join('').toUpperCase() || 'U';
|
||||
}
|
||||
|
||||
// Check if response indicates authentication failure and redirect to login if needed
|
||||
async function handleAuthenticationError(response) {
|
||||
// Check if we got HTML instead of JSON (indicates OIDC redirect to login)
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('text/html')) {
|
||||
// Session expired - redirect to root to trigger OIDC authentication flow
|
||||
window.location.href = '/';
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also check for explicit auth errors
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
window.location.href = '/';
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Load user information
|
||||
async function loadUserInfo() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/user');
|
||||
if (response.ok) {
|
||||
userInfo = await response.json();
|
||||
document.getElementById('username').textContent = userInfo.username;
|
||||
document.getElementById('userAvatar').textContent = getUserInitials(userInfo.username);
|
||||
} else if (await handleAuthenticationError(response)) {
|
||||
// Authentication error handled, no need to show error message
|
||||
return;
|
||||
} else {
|
||||
throw new Error('Failed to load user info');
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Failed to load user information');
|
||||
}
|
||||
}
|
||||
|
||||
// Load available servers
|
||||
async function loadServers() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/hosts');
|
||||
if (response.ok) {
|
||||
const servers = await response.json();
|
||||
renderServers(servers);
|
||||
} else if (await handleAuthenticationError(response)) {
|
||||
// Authentication error handled, no need to show error message
|
||||
return;
|
||||
} else {
|
||||
throw new Error('Failed to load servers');
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Failed to load available servers');
|
||||
}
|
||||
}
|
||||
|
||||
// Render servers in the grid
|
||||
function renderServers(servers) {
|
||||
const grid = document.getElementById('serversGrid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
servers.forEach(server => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'server-card';
|
||||
|
||||
const connectButton = document.createElement('button');
|
||||
connectButton.className = 'server-connect-button';
|
||||
connectButton.textContent = `Connect to ${server.name}`;
|
||||
connectButton.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
connectToServer(server, connectButton);
|
||||
};
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="server-content">
|
||||
<div class="server-icon">
|
||||
<img src="/assets/connect.svg" alt="Connect" />
|
||||
</div>
|
||||
<div class="server-info">
|
||||
<div class="server-name">${server.name}</div>
|
||||
<div class="server-description">${server.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
card.appendChild(connectButton);
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Show error message
|
||||
function showError(message) {
|
||||
const errorDiv = document.getElementById('error');
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.style.display = 'block';
|
||||
hideSuccess();
|
||||
}
|
||||
|
||||
// Hide error message
|
||||
function hideError() {
|
||||
document.getElementById('error').style.display = 'none';
|
||||
}
|
||||
|
||||
// Show success message
|
||||
function showSuccess(message) {
|
||||
const successDiv = document.getElementById('success');
|
||||
successDiv.textContent = message;
|
||||
successDiv.style.display = 'block';
|
||||
hideError();
|
||||
}
|
||||
|
||||
// Hide success message
|
||||
function hideSuccess() {
|
||||
document.getElementById('success').style.display = 'none';
|
||||
}
|
||||
|
||||
// Animate progress bar
|
||||
function animateProgress(duration = config.progressAnimationDuration) {
|
||||
const progressFill = document.getElementById('progressFill');
|
||||
progressFill.style.width = '0%';
|
||||
|
||||
let startTime = null;
|
||||
function animate(currentTime) {
|
||||
if (!startTime) startTime = currentTime;
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
progressFill.style.width = (progress * 100) + '%';
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
// Generate filename with user initials and random prefix
|
||||
function generateFilename() {
|
||||
if (!userInfo) return 'connection.rdp';
|
||||
|
||||
const initials = getUserInitials(userInfo.username);
|
||||
const randomStr = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
return `${initials}_${randomStr}.rdp`;
|
||||
}
|
||||
|
||||
// Download RDP file
|
||||
async function downloadRDPFile(url) {
|
||||
// First check if the download URL is accessible to detect authentication errors
|
||||
try {
|
||||
const checkResponse = await fetch(url, { method: 'HEAD' });
|
||||
if (await handleAuthenticationError(checkResponse)) {
|
||||
return; // Will redirect to login
|
||||
}
|
||||
if (!checkResponse.ok) {
|
||||
throw new Error(`Download failed: ${checkResponse.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// If HEAD request fails, still try the download - might be a CORS issue
|
||||
console.warn('HEAD request failed, proceeding with download:', error);
|
||||
}
|
||||
|
||||
// Proceed with download
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = generateFilename();
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
// Connect to server
|
||||
async function connectToServer(server, button) {
|
||||
if (!server) return;
|
||||
|
||||
hideError();
|
||||
hideSuccess();
|
||||
|
||||
const originalButtonText = button.textContent;
|
||||
const loading = document.getElementById('loading');
|
||||
|
||||
// Update UI for loading state
|
||||
button.disabled = true;
|
||||
button.textContent = 'Downloading...';
|
||||
loading.style.display = 'block';
|
||||
|
||||
// Start progress animation
|
||||
animateProgress();
|
||||
|
||||
try {
|
||||
// Build the RDP download URL
|
||||
let url = '/connect';
|
||||
if (server.address) {
|
||||
url += '?host=' + encodeURIComponent(server.address);
|
||||
}
|
||||
|
||||
// Wait a moment for better UX
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Download the RDP file
|
||||
await downloadRDPFile(url);
|
||||
showSuccess('RDP file downloaded. Please open it with your RDP client.');
|
||||
|
||||
// Reset UI after a delay
|
||||
setTimeout(() => {
|
||||
button.disabled = false;
|
||||
button.textContent = originalButtonText;
|
||||
loading.style.display = 'none';
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Connection error:', error);
|
||||
showError('Failed to download RDP file. Please try again.');
|
||||
|
||||
// Reset UI immediately on error
|
||||
button.disabled = false;
|
||||
button.textContent = originalButtonText;
|
||||
loading.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Initialize the application
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Set initial logo based on theme
|
||||
updateLogo();
|
||||
|
||||
// Load data
|
||||
await loadUserInfo();
|
||||
await loadServers();
|
||||
|
||||
// No additional event handlers needed - buttons are handled in renderServers
|
||||
});
|
||||
|
||||
// Handle visibility change (for auto-refresh when tab becomes visible)
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) {
|
||||
// Refresh server list when tab becomes visible
|
||||
loadServers();
|
||||
}
|
||||
});
|
||||
22
cmd/rdpgw/templates/config-example.json
Normal file
22
cmd/rdpgw/templates/config-example.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"branding": {
|
||||
"title": "RDP Gateway",
|
||||
"logo": "RDP Gateway",
|
||||
"page_title": "Select a Server to Connect"
|
||||
},
|
||||
"messages": {
|
||||
"select_server": "Select a server to connect",
|
||||
"preparing": "Preparing your connection..."
|
||||
},
|
||||
"ui": {
|
||||
"progress_animation_duration_ms": 2000,
|
||||
"auto_select_default": true,
|
||||
"show_user_avatar": true
|
||||
},
|
||||
"theme": {
|
||||
"primary_color": "#667eea",
|
||||
"secondary_color": "#764ba2",
|
||||
"success_color": "#38b2ac",
|
||||
"error_color": "#c53030"
|
||||
}
|
||||
}
|
||||
45
cmd/rdpgw/templates/index.html
Normal file
45
cmd/rdpgw/templates/index.html
Normal file
@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/icon.svg">
|
||||
<link rel="alternate icon" type="image/x-icon" href="/assets/icon.svg">
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="logo">
|
||||
<img src="/assets/icon.svg" alt="Logo" id="logoImage">
|
||||
{{.Logo}}
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="user-avatar" id="userAvatar"></div>
|
||||
<span id="username">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<div class="container">
|
||||
<h1 class="title">{{.PageTitle}}</h1>
|
||||
|
||||
<div class="error" id="error"></div>
|
||||
<div class="success" id="success"></div>
|
||||
|
||||
<div class="servers-grid" id="serversGrid">
|
||||
<!-- Servers will be loaded here -->
|
||||
</div>
|
||||
|
||||
<div class="loading" id="loading">
|
||||
{{.PreparingMessage}}
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="progressFill"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
305
cmd/rdpgw/templates/style.css
Normal file
305
cmd/rdpgw/templates/style.css
Normal file
@ -0,0 +1,305 @@
|
||||
:root {
|
||||
/* Light mode colors (OKLCH) */
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.466 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
|
||||
/* Border radius - Pocket ID system */
|
||||
--radius: 0.75rem;
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
/* Dark mode colors */
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(0.269 0 0);
|
||||
--muted: oklch(0.205 0 0);
|
||||
--muted-foreground: oklch(0.722 0 0);
|
||||
--card: oklch(0.145 0 0);
|
||||
--popover: oklch(0.145 0 0);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: color 0.2s, background-color 0.2s;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: var(--card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.logo img {
|
||||
height: 2rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--secondary);
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: var(--card);
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--border);
|
||||
padding: 2rem;
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
color: var(--foreground);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.servers-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.server-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
background: var(--card);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.server-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
|
||||
.server-content {
|
||||
padding: 1.5rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.server-icon {
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.server-icon img {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.server-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.server-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--foreground);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.server-description {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.server-connect-button {
|
||||
width: 100%;
|
||||
background: var(--primary);
|
||||
color: var(--background);
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
||||
padding: 1rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.server-connect-button:hover:not(:disabled) {
|
||||
background: var(--primary);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.server-connect-button:disabled {
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: none;
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
color: var(--foreground);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: color-mix(in oklch, var(--destructive) 10%, transparent);
|
||||
color: var(--destructive);
|
||||
border: 1px solid color-mix(in oklch, var(--destructive) 20%, transparent);
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 1rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.success {
|
||||
background: color-mix(in oklch, var(--primary) 10%, transparent);
|
||||
color: var(--primary);
|
||||
border: 1px solid color-mix(in oklch, var(--primary) 20%, transparent);
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 1rem;
|
||||
display: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
width: 0%;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.servers-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
83
cmd/rdpgw/web/header.go
Normal file
83
cmd/rdpgw/web/header.go
Normal file
@ -0,0 +1,83 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/identity"
|
||||
)
|
||||
|
||||
type Header struct {
|
||||
userHeader string
|
||||
userIdHeader string
|
||||
emailHeader string
|
||||
displayNameHeader string
|
||||
}
|
||||
|
||||
type HeaderConfig struct {
|
||||
UserHeader string
|
||||
UserIdHeader string
|
||||
EmailHeader string
|
||||
DisplayNameHeader string
|
||||
}
|
||||
|
||||
func (c *HeaderConfig) New() *Header {
|
||||
return &Header{
|
||||
userHeader: c.UserHeader,
|
||||
userIdHeader: c.UserIdHeader,
|
||||
emailHeader: c.EmailHeader,
|
||||
displayNameHeader: c.DisplayNameHeader,
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticated middleware that extracts user identity from configurable proxy headers
|
||||
func (h *Header) Authenticated(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := identity.FromRequestCtx(r)
|
||||
|
||||
// Check if user is already authenticated
|
||||
if id.Authenticated() {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract username from configured user header
|
||||
userName := r.Header.Get(h.userHeader)
|
||||
if userName == "" {
|
||||
http.Error(w, "No authenticated user from proxy", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Set identity for downstream processing
|
||||
id.SetUserName(userName)
|
||||
id.SetAuthenticated(true)
|
||||
id.SetAuthTime(time.Now())
|
||||
|
||||
// Set optional user attributes from headers
|
||||
if h.userIdHeader != "" {
|
||||
if userId := r.Header.Get(h.userIdHeader); userId != "" {
|
||||
id.SetAttribute("user_id", userId)
|
||||
}
|
||||
}
|
||||
|
||||
if h.emailHeader != "" {
|
||||
if email := r.Header.Get(h.emailHeader); email != "" {
|
||||
id.SetEmail(email)
|
||||
}
|
||||
}
|
||||
|
||||
if h.displayNameHeader != "" {
|
||||
if displayName := r.Header.Get(h.displayNameHeader); displayName != "" {
|
||||
id.SetDisplayName(displayName)
|
||||
}
|
||||
}
|
||||
|
||||
// Save the session identity
|
||||
if err := SaveSessionIdentity(r, w, id); err != nil {
|
||||
http.Error(w, "Failed to save session: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
318
cmd/rdpgw/web/header_test.go
Normal file
318
cmd/rdpgw/web/header_test.go
Normal file
@ -0,0 +1,318 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/identity"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Initialize session store for testing
|
||||
sessionKey := []byte("thisisasessionkeyreplacethisjetzt")
|
||||
encryptionKey := []byte("thisisasessionencryptionkey12345")
|
||||
InitStore(sessionKey, encryptionKey, "cookie", 8192)
|
||||
}
|
||||
|
||||
func TestHeaderAuthenticated(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
headers map[string]string
|
||||
expectedStatusCode int
|
||||
expectedAuth bool
|
||||
expectedUser string
|
||||
expectedEmail string
|
||||
expectedDisplayName string
|
||||
expectedUserId string
|
||||
}{
|
||||
{
|
||||
name: "ms_app_proxy_headers",
|
||||
headers: map[string]string{
|
||||
"X-MS-CLIENT-PRINCIPAL-NAME": "user@domain.com",
|
||||
"X-MS-CLIENT-PRINCIPAL-ID": "12345-abcdef",
|
||||
"X-MS-CLIENT-PRINCIPAL-EMAIL": "user@domain.com",
|
||||
},
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedAuth: true,
|
||||
expectedUser: "user@domain.com",
|
||||
expectedEmail: "user@domain.com",
|
||||
expectedUserId: "12345-abcdef",
|
||||
},
|
||||
{
|
||||
name: "google_iap_headers",
|
||||
headers: map[string]string{
|
||||
"X-Goog-Authenticated-User-Email": "testuser@example.org",
|
||||
"X-Goog-Authenticated-User-ID": "google-user-123",
|
||||
},
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedAuth: true,
|
||||
expectedUser: "testuser@example.org",
|
||||
expectedEmail: "testuser@example.org",
|
||||
expectedUserId: "google-user-123",
|
||||
},
|
||||
{
|
||||
name: "aws_alb_headers",
|
||||
headers: map[string]string{
|
||||
"X-Amzn-Oidc-Subject": "aws-user-456",
|
||||
"X-Amzn-Oidc-Email": "awsuser@company.com",
|
||||
"X-Amzn-Oidc-Name": "AWS User",
|
||||
},
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedAuth: true,
|
||||
expectedUser: "aws-user-456",
|
||||
expectedEmail: "awsuser@company.com",
|
||||
expectedDisplayName: "AWS User",
|
||||
},
|
||||
{
|
||||
name: "custom_headers",
|
||||
headers: map[string]string{
|
||||
"X-Forwarded-User": "customuser",
|
||||
"X-Forwarded-Email": "custom@example.com",
|
||||
"X-Forwarded-Name": "Custom User",
|
||||
},
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedAuth: true,
|
||||
expectedUser: "customuser",
|
||||
expectedEmail: "custom@example.com",
|
||||
expectedDisplayName: "Custom User",
|
||||
},
|
||||
{
|
||||
name: "missing_user_header",
|
||||
headers: map[string]string{"X-Some-Other-Header": "value"},
|
||||
expectedStatusCode: http.StatusUnauthorized,
|
||||
expectedAuth: false,
|
||||
expectedUser: "",
|
||||
},
|
||||
{
|
||||
name: "empty_headers",
|
||||
headers: map[string]string{},
|
||||
expectedStatusCode: http.StatusUnauthorized,
|
||||
expectedAuth: false,
|
||||
expectedUser: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create a test handler that checks the identity
|
||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := identity.FromRequestCtx(r)
|
||||
if id.Authenticated() != tc.expectedAuth {
|
||||
t.Errorf("expected authenticated: %v, got: %v", tc.expectedAuth, id.Authenticated())
|
||||
}
|
||||
if id.UserName() != tc.expectedUser {
|
||||
t.Errorf("expected username: %v, got: %v", tc.expectedUser, id.UserName())
|
||||
}
|
||||
if tc.expectedEmail != "" && id.Email() != tc.expectedEmail {
|
||||
t.Errorf("expected email: %v, got: %v", tc.expectedEmail, id.Email())
|
||||
}
|
||||
if tc.expectedDisplayName != "" && id.DisplayName() != tc.expectedDisplayName {
|
||||
t.Errorf("expected display name: %v, got: %v", tc.expectedDisplayName, id.DisplayName())
|
||||
}
|
||||
if tc.expectedUserId != "" {
|
||||
if userId := id.GetAttribute("user_id"); userId != tc.expectedUserId {
|
||||
t.Errorf("expected user_id: %v, got: %v", tc.expectedUserId, userId)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
// Determine header config based on test case
|
||||
var headerConfig *HeaderConfig
|
||||
switch tc.name {
|
||||
case "ms_app_proxy_headers":
|
||||
headerConfig = &HeaderConfig{
|
||||
UserHeader: "X-MS-CLIENT-PRINCIPAL-NAME",
|
||||
UserIdHeader: "X-MS-CLIENT-PRINCIPAL-ID",
|
||||
EmailHeader: "X-MS-CLIENT-PRINCIPAL-EMAIL",
|
||||
DisplayNameHeader: "",
|
||||
}
|
||||
case "google_iap_headers":
|
||||
headerConfig = &HeaderConfig{
|
||||
UserHeader: "X-Goog-Authenticated-User-Email",
|
||||
UserIdHeader: "X-Goog-Authenticated-User-ID",
|
||||
EmailHeader: "X-Goog-Authenticated-User-Email",
|
||||
}
|
||||
case "aws_alb_headers":
|
||||
headerConfig = &HeaderConfig{
|
||||
UserHeader: "X-Amzn-Oidc-Subject",
|
||||
EmailHeader: "X-Amzn-Oidc-Email",
|
||||
DisplayNameHeader: "X-Amzn-Oidc-Name",
|
||||
}
|
||||
case "custom_headers":
|
||||
headerConfig = &HeaderConfig{
|
||||
UserHeader: "X-Forwarded-User",
|
||||
EmailHeader: "X-Forwarded-Email",
|
||||
DisplayNameHeader: "X-Forwarded-Name",
|
||||
}
|
||||
default:
|
||||
headerConfig = &HeaderConfig{
|
||||
UserHeader: "X-Forwarded-User",
|
||||
}
|
||||
}
|
||||
|
||||
headerAuth := headerConfig.New()
|
||||
|
||||
// Wrap test handler with authentication
|
||||
authHandler := headerAuth.Authenticated(testHandler)
|
||||
|
||||
// Create test request
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
|
||||
// Add headers from test case
|
||||
for header, value := range tc.headers {
|
||||
req.Header.Set(header, value)
|
||||
}
|
||||
|
||||
// Add identity to request context (simulating middleware)
|
||||
testId := identity.NewUser()
|
||||
req = identity.AddToRequestCtx(testId, req)
|
||||
|
||||
// Create response recorder
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
// Execute the handler
|
||||
authHandler.ServeHTTP(rr, req)
|
||||
|
||||
// Check status code
|
||||
if rr.Code != tc.expectedStatusCode {
|
||||
t.Errorf("expected status code: %v, got: %v", tc.expectedStatusCode, rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderAlreadyAuthenticated(t *testing.T) {
|
||||
// Create a test handler that checks the identity
|
||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := identity.FromRequestCtx(r)
|
||||
if !id.Authenticated() {
|
||||
t.Error("expected user to remain authenticated")
|
||||
}
|
||||
if id.UserName() != "existing_user" {
|
||||
t.Errorf("expected username to remain: existing_user, got: %v", id.UserName())
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
// Create header auth handler
|
||||
headerConfig := &HeaderConfig{
|
||||
UserHeader: "X-Forwarded-User",
|
||||
}
|
||||
headerAuth := headerConfig.New()
|
||||
|
||||
// Wrap test handler with authentication
|
||||
authHandler := headerAuth.Authenticated(testHandler)
|
||||
|
||||
// Create test request
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("X-Forwarded-User", "new_user@domain.com")
|
||||
|
||||
// Add pre-authenticated identity to request context
|
||||
testId := identity.NewUser()
|
||||
testId.SetUserName("existing_user")
|
||||
testId.SetAuthenticated(true)
|
||||
testId.SetAuthTime(time.Now())
|
||||
req = identity.AddToRequestCtx(testId, req)
|
||||
|
||||
// Create response recorder
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
// Execute the handler
|
||||
authHandler.ServeHTTP(rr, req)
|
||||
|
||||
// Check status code
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("expected status code: %v, got: %v", http.StatusOK, rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderConfigValidation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
config *HeaderConfig
|
||||
valid bool
|
||||
}{
|
||||
{
|
||||
name: "valid_config",
|
||||
config: &HeaderConfig{
|
||||
UserHeader: "X-Forwarded-User",
|
||||
},
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "full_config",
|
||||
config: &HeaderConfig{
|
||||
UserHeader: "X-MS-CLIENT-PRINCIPAL-NAME",
|
||||
UserIdHeader: "X-MS-CLIENT-PRINCIPAL-ID",
|
||||
EmailHeader: "X-MS-CLIENT-PRINCIPAL-EMAIL",
|
||||
DisplayNameHeader: "X-MS-CLIENT-PRINCIPAL-NAME",
|
||||
},
|
||||
valid: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
header := tc.config.New()
|
||||
if header == nil && tc.valid {
|
||||
t.Error("expected valid header instance")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderConfig(t *testing.T) {
|
||||
config := &HeaderConfig{}
|
||||
header := config.New()
|
||||
|
||||
if header == nil {
|
||||
t.Error("expected non-nil Header instance")
|
||||
}
|
||||
}
|
||||
|
||||
// Test that the authentication flow sets the correct attributes
|
||||
func TestHeaderAttributesSetting(t *testing.T) {
|
||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := identity.FromRequestCtx(r)
|
||||
|
||||
// Check that auth time is set and recent
|
||||
authTime := id.AuthTime()
|
||||
if authTime.IsZero() {
|
||||
t.Error("expected auth time to be set")
|
||||
}
|
||||
if time.Since(authTime) > time.Minute {
|
||||
t.Error("auth time should be recent")
|
||||
}
|
||||
|
||||
// Check that user_id attribute is set
|
||||
if userId := id.GetAttribute("user_id"); userId != "test-id-123" {
|
||||
t.Errorf("expected user_id: test-id-123, got: %v", userId)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
headerConfig := &HeaderConfig{
|
||||
UserHeader: "X-Forwarded-User",
|
||||
UserIdHeader: "X-Forwarded-User-Id",
|
||||
}
|
||||
headerAuth := headerConfig.New()
|
||||
authHandler := headerAuth.Authenticated(testHandler)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("X-Forwarded-User", "user@domain.com")
|
||||
req.Header.Set("X-Forwarded-User-Id", "test-id-123")
|
||||
|
||||
testId := identity.NewUser()
|
||||
req = identity.AddToRequestCtx(testId, req)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
authHandler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("expected status code: %v, got: %v", http.StatusOK, rr.Code)
|
||||
}
|
||||
}
|
||||
@ -4,24 +4,25 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/identity"
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/patrickmn/go-cache"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
const (
|
||||
CacheExpiration = time.Minute * 2
|
||||
CleanupInterval = time.Minute * 5
|
||||
oidcStateKey = "OIDCSTATE"
|
||||
)
|
||||
|
||||
type OIDC struct {
|
||||
oAuth2Config *oauth2.Config
|
||||
oidcTokenVerifier *oidc.IDTokenVerifier
|
||||
stateStore *cache.Cache
|
||||
}
|
||||
|
||||
type OIDCConfig struct {
|
||||
@ -33,18 +34,63 @@ func (c *OIDCConfig) New() *OIDC {
|
||||
return &OIDC{
|
||||
oAuth2Config: c.OAuth2Config,
|
||||
oidcTokenVerifier: c.OIDCTokenVerifier,
|
||||
stateStore: cache.New(CacheExpiration, CleanupInterval),
|
||||
}
|
||||
}
|
||||
|
||||
// storeOIDCState stores the OIDC state and redirect URL in the session
|
||||
func storeOIDCState(w http.ResponseWriter, r *http.Request, state string, redirectURL string) error {
|
||||
session, err := GetSession(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Store state data directly as a concatenated string: state + "|" + redirectURL
|
||||
stateValue := state + "|" + redirectURL
|
||||
session.Values[oidcStateKey] = stateValue
|
||||
session.Options.MaxAge = int(CacheExpiration.Seconds())
|
||||
|
||||
return sessionStore.Save(r, w, session)
|
||||
}
|
||||
|
||||
// getOIDCState retrieves the redirect URL for the given state from the session
|
||||
func getOIDCState(r *http.Request, state string) (string, bool) {
|
||||
session, err := GetSession(r)
|
||||
if err != nil {
|
||||
log.Printf("Error getting session for OIDC state: %v", err)
|
||||
return "", false
|
||||
}
|
||||
|
||||
stateData, exists := session.Values[oidcStateKey]
|
||||
if !exists {
|
||||
log.Printf("No OIDC state data found in session")
|
||||
return "", false
|
||||
}
|
||||
|
||||
stateValue, ok := stateData.(string)
|
||||
if !ok {
|
||||
log.Printf("Invalid OIDC state data format in session")
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Parse state data: state + "|" + redirectURL
|
||||
expectedPrefix := state + "|"
|
||||
if !strings.HasPrefix(stateValue, expectedPrefix) {
|
||||
log.Printf("OIDC state '%s' not found in session", state)
|
||||
return "", false
|
||||
}
|
||||
|
||||
redirectURL := stateValue[len(expectedPrefix):]
|
||||
return redirectURL, true
|
||||
}
|
||||
|
||||
func (h *OIDC) HandleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
state := r.URL.Query().Get("state")
|
||||
s, found := h.stateStore.Get(state)
|
||||
url, found := getOIDCState(r, state)
|
||||
if !found {
|
||||
log.Printf("OIDC HandleCallback: unknown state '%s'", state)
|
||||
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"))
|
||||
@ -123,7 +169,15 @@ func (h *OIDC) Authenticated(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
state := hex.EncodeToString(seed)
|
||||
h.stateStore.Set(state, r.RequestURI, cache.DefaultExpiration)
|
||||
|
||||
log.Printf("OIDC Authenticated: storing state '%s' for redirect to '%s'", state, r.RequestURI)
|
||||
err = storeOIDCState(w, r, state, r.RequestURI)
|
||||
if err != nil {
|
||||
log.Printf("OIDC Authenticated: failed to store state: %v", err)
|
||||
http.Error(w, "Failed to store OIDC state", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, h.oAuth2Config.AuthCodeURL(state), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
package web
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFindUserNameInClaims(t *testing.T) {
|
||||
cases := []struct {
|
||||
@ -47,3 +50,66 @@ func TestFindUserNameInClaims(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCStateManagement(t *testing.T) {
|
||||
// Initialize session store for testing
|
||||
sessionKey := []byte("testsessionkeytestsessionkey1234") // 32 bytes
|
||||
encryptionKey := []byte("testencryptionkeytestencrypt1234") // 32 bytes
|
||||
InitStore(sessionKey, encryptionKey, "cookie", 8192)
|
||||
|
||||
// Test storing and retrieving OIDC state
|
||||
t.Run("StoreAndRetrieveState", func(t *testing.T) {
|
||||
// Create test request and response
|
||||
req := httptest.NewRequest("GET", "/connect", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
state := "test-state-12345"
|
||||
redirectURL := "/original-request"
|
||||
|
||||
// Store state
|
||||
err := storeOIDCState(w, req, state, redirectURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to store OIDC state: %v", err)
|
||||
}
|
||||
|
||||
// Create a new request with the same session cookie
|
||||
callbackReq := httptest.NewRequest("GET", "/callback", nil)
|
||||
|
||||
// Copy session cookie from response to new request
|
||||
for _, cookie := range w.Result().Cookies() {
|
||||
callbackReq.AddCookie(cookie)
|
||||
}
|
||||
|
||||
// Retrieve state
|
||||
retrievedURL, found := getOIDCState(callbackReq, state)
|
||||
if !found {
|
||||
t.Fatal("Expected to find OIDC state, but it was not found")
|
||||
}
|
||||
|
||||
if retrievedURL != redirectURL {
|
||||
t.Fatalf("Expected redirect URL '%s', got '%s'", redirectURL, retrievedURL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("StateNotFound", func(t *testing.T) {
|
||||
// Create test request
|
||||
req := httptest.NewRequest("GET", "/callback", nil)
|
||||
|
||||
// Try to retrieve non-existent state
|
||||
_, found := getOIDCState(req, "non-existent-state")
|
||||
if found {
|
||||
t.Fatal("Expected state not to be found, but it was found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("EmptySession", func(t *testing.T) {
|
||||
// Create fresh request with no session
|
||||
req := httptest.NewRequest("GET", "/callback", nil)
|
||||
|
||||
// Try to retrieve state from empty session
|
||||
_, found := getOIDCState(req, "any-state")
|
||||
if found {
|
||||
t.Fatal("Expected state not to be found in empty session, but it was found")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -5,13 +5,17 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/maphash"
|
||||
"html/template"
|
||||
"log"
|
||||
rnd "math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -38,6 +42,31 @@ type Config struct {
|
||||
TemplateFile string
|
||||
RdpSigningCert string
|
||||
RdpSigningKey string
|
||||
TemplatesPath string
|
||||
}
|
||||
|
||||
// WebConfig represents the web interface configuration
|
||||
type WebConfig struct {
|
||||
Branding struct {
|
||||
Title string `json:"title"`
|
||||
Logo string `json:"logo"`
|
||||
PageTitle string `json:"page_title"`
|
||||
} `json:"branding"`
|
||||
Messages struct {
|
||||
SelectServer string `json:"select_server"`
|
||||
Preparing string `json:"preparing"`
|
||||
} `json:"messages"`
|
||||
UI struct {
|
||||
ProgressAnimationDurationMs int `json:"progress_animation_duration_ms"`
|
||||
AutoSelectDefault bool `json:"auto_select_default"`
|
||||
ShowUserAvatar bool `json:"show_user_avatar"`
|
||||
} `json:"ui"`
|
||||
Theme struct {
|
||||
PrimaryColor string `json:"primary_color"`
|
||||
SecondaryColor string `json:"secondary_color"`
|
||||
SuccessColor string `json:"success_color"`
|
||||
ErrorColor string `json:"error_color"`
|
||||
} `json:"theme"`
|
||||
}
|
||||
|
||||
type RdpOpts struct {
|
||||
@ -58,6 +87,9 @@ type Handler struct {
|
||||
rdpOpts RdpOpts
|
||||
rdpDefaults string
|
||||
rdpSigner *rdpsign.Signer
|
||||
templatesPath string
|
||||
webConfig *WebConfig
|
||||
htmlTemplate *template.Template
|
||||
}
|
||||
|
||||
func (c *Config) NewHandler() *Handler {
|
||||
@ -76,6 +108,7 @@ func (c *Config) NewHandler() *Handler {
|
||||
hostSelection: c.HostSelection,
|
||||
rdpOpts: c.RdpOpts,
|
||||
rdpDefaults: c.TemplateFile,
|
||||
templatesPath: c.TemplatesPath,
|
||||
}
|
||||
|
||||
// set up RDP signer if config values are set
|
||||
@ -88,9 +121,231 @@ func (c *Config) NewHandler() *Handler {
|
||||
handler.rdpSigner = signer
|
||||
}
|
||||
|
||||
// Set up templates path
|
||||
if handler.templatesPath == "" {
|
||||
handler.templatesPath = "./templates"
|
||||
}
|
||||
|
||||
// Load web configuration
|
||||
handler.loadWebConfig()
|
||||
|
||||
// Load HTML template
|
||||
handler.loadHTMLTemplate()
|
||||
|
||||
return handler
|
||||
}
|
||||
|
||||
// loadWebConfig sets up the web interface configuration with defaults
|
||||
func (h *Handler) loadWebConfig() {
|
||||
// Set defaults - these can be overridden by the main config system later
|
||||
h.webConfig = &WebConfig{}
|
||||
h.webConfig.Branding.Title = "RDP Gateway"
|
||||
h.webConfig.Branding.Logo = "RDP Gateway"
|
||||
h.webConfig.Branding.PageTitle = "Select a Server to Connect"
|
||||
h.webConfig.Messages.SelectServer = "Select a server to connect"
|
||||
h.webConfig.Messages.Preparing = "Preparing your connection..."
|
||||
h.webConfig.UI.ProgressAnimationDurationMs = 2000
|
||||
h.webConfig.UI.AutoSelectDefault = true
|
||||
h.webConfig.UI.ShowUserAvatar = true
|
||||
h.webConfig.Theme.PrimaryColor = "#667eea"
|
||||
h.webConfig.Theme.SecondaryColor = "#764ba2"
|
||||
h.webConfig.Theme.SuccessColor = "#38b2ac"
|
||||
h.webConfig.Theme.ErrorColor = "#c53030"
|
||||
}
|
||||
|
||||
// loadHTMLTemplate loads the HTML template
|
||||
func (h *Handler) loadHTMLTemplate() {
|
||||
templatePath := filepath.Join(h.templatesPath, "index.html")
|
||||
|
||||
tmpl, err := template.ParseFiles(templatePath)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to load HTML template %s: %v", templatePath, err)
|
||||
log.Printf("Using embedded fallback template")
|
||||
h.htmlTemplate = template.Must(template.New("index").Parse(fallbackHTMLTemplate))
|
||||
} else {
|
||||
h.htmlTemplate = tmpl
|
||||
log.Printf("Loaded HTML template from %s", templatePath)
|
||||
}
|
||||
}
|
||||
|
||||
// ServeStaticFile serves static files from the templates directory
|
||||
func (h *Handler) ServeStaticFile(filename string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
filePath := filepath.Join(h.templatesPath, filename)
|
||||
|
||||
// Check if file exists
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Set appropriate content type
|
||||
switch filepath.Ext(filename) {
|
||||
case ".css":
|
||||
w.Header().Set("Content-Type", "text/css")
|
||||
case ".js":
|
||||
w.Header().Set("Content-Type", "application/javascript")
|
||||
case ".svg":
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
case ".png":
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
case ".jpg", ".jpeg":
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
default:
|
||||
// Check if it's one of our logo files without extension
|
||||
if filename == "logo.png" || filename == "logo_light_background.png" || filename == "logo_dark_background.png" {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
}
|
||||
}
|
||||
|
||||
// Enable caching for static files
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
|
||||
http.ServeFile(w, r, filePath)
|
||||
}
|
||||
}
|
||||
|
||||
// ServeAssetFile serves asset files from the assets directory
|
||||
func (h *Handler) ServeAssetFile(filename string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var filePath string
|
||||
|
||||
// Try multiple possible locations for assets
|
||||
possiblePaths := []string{
|
||||
// Docker container paths
|
||||
"./assets/" + filename,
|
||||
"/app/assets/" + filename,
|
||||
"/opt/rdpgw/assets/" + filename,
|
||||
// Development paths
|
||||
filepath.Join("assets", filename),
|
||||
}
|
||||
|
||||
// Add icon.svg to the check as well
|
||||
if filename == "icon.svg" {
|
||||
possiblePaths = append(possiblePaths, "./icon.svg", "/app/icon.svg", "/opt/rdpgw/icon.svg")
|
||||
}
|
||||
|
||||
// If we have templates path, try relative to it
|
||||
if h.templatesPath != "" {
|
||||
templatesDir, err := filepath.Abs(h.templatesPath)
|
||||
if err == nil {
|
||||
// Navigate up from templates to find assets
|
||||
currentDir := templatesDir
|
||||
for i := 0; i < 5; i++ {
|
||||
parentDir := filepath.Dir(currentDir)
|
||||
if parentDir == currentDir {
|
||||
break
|
||||
}
|
||||
possiblePaths = append(possiblePaths, filepath.Join(parentDir, "assets", filename))
|
||||
currentDir = parentDir
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test each possible path
|
||||
for _, testPath := range possiblePaths {
|
||||
if _, err := os.Stat(testPath); err == nil {
|
||||
filePath = testPath
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if filePath == "" {
|
||||
log.Printf("Asset file not found: %s. Tried paths: %v", filename, possiblePaths)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Set appropriate content type
|
||||
switch filepath.Ext(filename) {
|
||||
case ".svg":
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
case ".png":
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
case ".jpg", ".jpeg":
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
default:
|
||||
// Check if it's one of our asset files without extension
|
||||
if filename == "logo_light_background.png" || filename == "logo_dark_background.png" || filename == "connect.svg" {
|
||||
if filepath.Ext(filename) == ".png" || filename == "logo_light_background.png" || filename == "logo_dark_background.png" {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
} else {
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enable caching for asset files
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
|
||||
http.ServeFile(w, r, filePath)
|
||||
}
|
||||
}
|
||||
|
||||
// fallbackHTMLTemplate is used when external template file is not available
|
||||
const fallbackHTMLTemplate = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}}</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; }
|
||||
.container { max-width: 800px; margin: 2rem auto; padding: 2rem; background: white; border-radius: 12px; }
|
||||
.server-card { border: 2px solid #e2e8f0; border-radius: 8px; padding: 1.5rem; margin: 1rem; cursor: pointer; }
|
||||
.server-card:hover { border-color: #667eea; }
|
||||
.server-card.selected { border-color: #667eea; background: rgba(102, 126, 234, 0.05); }
|
||||
.connect-button { width: 100%; background: #667eea; color: white; border: none; border-radius: 8px;
|
||||
padding: 1rem 2rem; font-size: 1.1rem; cursor: pointer; }
|
||||
.connect-button:disabled { background: #a0aec0; cursor: not-allowed; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>{{.PageTitle}}</h1>
|
||||
<div id="serversGrid"></div>
|
||||
<button class="connect-button" id="connectButton" disabled>{{.SelectServerMessage}}</button>
|
||||
<div id="loading" style="display:none;">{{.PreparingMessage}}</div>
|
||||
</div>
|
||||
<script>
|
||||
// Fallback minimal JavaScript
|
||||
let selectedServer = null;
|
||||
async function loadServers() {
|
||||
const response = await fetch('/api/hosts');
|
||||
const servers = await response.json();
|
||||
const grid = document.getElementById('serversGrid');
|
||||
servers.forEach(server => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'server-card';
|
||||
card.innerHTML = server.icon + ' ' + server.name + '<br><small>' + server.description + '</small>';
|
||||
card.onclick = () => {
|
||||
document.querySelectorAll('.server-card').forEach(c => c.classList.remove('selected'));
|
||||
card.classList.add('selected');
|
||||
selectedServer = server;
|
||||
document.getElementById('connectButton').disabled = false;
|
||||
};
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
async function connectToServer() {
|
||||
if (!selectedServer) return;
|
||||
let url = '/connect';
|
||||
if (selectedServer.address) url += '?host=' + encodeURIComponent(selectedServer.address);
|
||||
window.location.href = url;
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', loadServers);
|
||||
document.getElementById('connectButton').onclick = connectToServer;
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
func (h *Handler) selectRandomHost() string {
|
||||
r := rnd.New(rnd.NewSource(int64(new(maphash.Hash).Sum64())))
|
||||
host := h.hosts[r.Intn(len(h.hosts))]
|
||||
@ -271,3 +526,107 @@ func (h *Handler) HandleDownload(w http.ResponseWriter, r *http.Request) {
|
||||
// return signd rdp file
|
||||
http.ServeContent(w, r, fn, time.Now(), bytes.NewReader(signedContent))
|
||||
}
|
||||
|
||||
// Host represents a host available for connection
|
||||
type Host struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
Description string `json:"description"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
}
|
||||
|
||||
// UserInfo represents the current authenticated user
|
||||
type UserInfo struct {
|
||||
Username string `json:"username"`
|
||||
Authenticated bool `json:"authenticated"`
|
||||
AuthTime time.Time `json:"authTime"`
|
||||
}
|
||||
|
||||
// HandleHostList returns the list of available hosts for the authenticated user
|
||||
func (h *Handler) HandleHostList(w http.ResponseWriter, r *http.Request) {
|
||||
id := identity.FromRequestCtx(r)
|
||||
|
||||
if !id.Authenticated() {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var hosts []Host
|
||||
|
||||
// Simplified host selection - all modes work the same for the user
|
||||
if h.hostSelection == "roundrobin" {
|
||||
hosts = append(hosts, Host{
|
||||
ID: "roundrobin",
|
||||
Name: "Available Servers",
|
||||
Address: "",
|
||||
Description: "Connect to an available server automatically",
|
||||
IsDefault: true,
|
||||
})
|
||||
} else {
|
||||
// For all other modes (signed, unsigned, any), show the actual hosts
|
||||
for i, hostAddr := range h.hosts {
|
||||
hosts = append(hosts, Host{
|
||||
ID: fmt.Sprintf("host_%d", i),
|
||||
Name: hostAddr,
|
||||
Address: hostAddr,
|
||||
Description: fmt.Sprintf("Connect to %s", hostAddr),
|
||||
IsDefault: i == 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(hosts)
|
||||
}
|
||||
|
||||
// HandleUserInfo returns information about the current authenticated user
|
||||
func (h *Handler) HandleUserInfo(w http.ResponseWriter, r *http.Request) {
|
||||
id := identity.FromRequestCtx(r)
|
||||
|
||||
if !id.Authenticated() {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userInfo := UserInfo{
|
||||
Username: id.UserName(),
|
||||
Authenticated: id.Authenticated(),
|
||||
AuthTime: id.AuthTime(),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(userInfo)
|
||||
}
|
||||
|
||||
// HandleWebInterface serves the main web interface
|
||||
func (h *Handler) HandleWebInterface(w http.ResponseWriter, r *http.Request) {
|
||||
id := identity.FromRequestCtx(r)
|
||||
|
||||
if !id.Authenticated() {
|
||||
// Redirect to authentication
|
||||
http.Redirect(w, r, "/connect", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Template data
|
||||
templateData := struct {
|
||||
Title string
|
||||
Logo string
|
||||
PageTitle string
|
||||
SelectServerMessage string
|
||||
PreparingMessage string
|
||||
}{
|
||||
Title: h.webConfig.Branding.Title,
|
||||
Logo: h.webConfig.Branding.Logo,
|
||||
PageTitle: h.webConfig.Branding.PageTitle,
|
||||
SelectServerMessage: h.webConfig.Messages.SelectServer,
|
||||
PreparingMessage: h.webConfig.Messages.Preparing,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
if err := h.htmlTemplate.Execute(w, templateData); err != nil {
|
||||
log.Printf("Failed to execute template: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
418
cmd/rdpgw/web/web_interface_test.go
Normal file
418
cmd/rdpgw/web/web_interface_test.go
Normal file
@ -0,0 +1,418 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bolkedebruin/rdpgw/cmd/rdpgw/identity"
|
||||
)
|
||||
|
||||
func TestHandleHostList(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hostSelection string
|
||||
hosts []string
|
||||
authenticated bool
|
||||
expectedCount int
|
||||
expectedType string
|
||||
}{
|
||||
{
|
||||
name: "roundrobin mode",
|
||||
hostSelection: "roundrobin",
|
||||
hosts: []string{"host1.example.com", "host2.example.com"},
|
||||
authenticated: true,
|
||||
expectedCount: 1,
|
||||
expectedType: "roundrobin",
|
||||
},
|
||||
{
|
||||
name: "unsigned mode",
|
||||
hostSelection: "unsigned",
|
||||
hosts: []string{"host1.example.com", "host2.example.com", "host3.example.com"},
|
||||
authenticated: true,
|
||||
expectedCount: 3,
|
||||
expectedType: "individual",
|
||||
},
|
||||
{
|
||||
name: "any mode",
|
||||
hostSelection: "any",
|
||||
hosts: []string{"host1.example.com"},
|
||||
authenticated: true,
|
||||
expectedCount: 1,
|
||||
expectedType: "individual",
|
||||
},
|
||||
{
|
||||
name: "signed mode",
|
||||
hostSelection: "signed",
|
||||
hosts: []string{"host1.example.com", "host2.example.com"},
|
||||
authenticated: true,
|
||||
expectedCount: 2,
|
||||
expectedType: "signed",
|
||||
},
|
||||
{
|
||||
name: "unauthenticated user",
|
||||
hostSelection: "roundrobin",
|
||||
hosts: []string{"host1.example.com"},
|
||||
authenticated: false,
|
||||
expectedCount: 0,
|
||||
expectedType: "error",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create handler
|
||||
handler := &Handler{
|
||||
hostSelection: tt.hostSelection,
|
||||
hosts: tt.hosts,
|
||||
}
|
||||
|
||||
// Create request
|
||||
req := httptest.NewRequest("GET", "/api/v1/hosts", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Set identity context
|
||||
user := identity.NewUser()
|
||||
if tt.authenticated {
|
||||
user.SetUserName("testuser")
|
||||
user.SetAuthenticated(true)
|
||||
user.SetAuthTime(time.Now())
|
||||
}
|
||||
req = identity.AddToRequestCtx(user, req)
|
||||
|
||||
// Call handler
|
||||
handler.HandleHostList(w, req)
|
||||
|
||||
if !tt.authenticated {
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, w.Code)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check response
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
var hosts []Host
|
||||
err := json.Unmarshal(w.Body.Bytes(), &hosts)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if len(hosts) != tt.expectedCount {
|
||||
t.Errorf("Expected %d hosts, got %d", tt.expectedCount, len(hosts))
|
||||
}
|
||||
|
||||
if len(hosts) > 0 {
|
||||
switch tt.expectedType {
|
||||
case "roundrobin":
|
||||
if hosts[0].ID != "roundrobin" {
|
||||
t.Errorf("Expected roundrobin host, got %s", hosts[0].ID)
|
||||
}
|
||||
case "individual":
|
||||
if !strings.Contains(hosts[0].Name, tt.hosts[0]) {
|
||||
t.Errorf("Expected host name to contain %s, got %s", tt.hosts[0], hosts[0].Name)
|
||||
}
|
||||
case "signed":
|
||||
if !strings.Contains(hosts[0].Name, tt.hosts[0]) {
|
||||
t.Errorf("Expected host name to contain %s, got %s", tt.hosts[0], hosts[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Check that first host is marked as default
|
||||
hasDefault := false
|
||||
for _, host := range hosts {
|
||||
if host.IsDefault {
|
||||
hasDefault = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasDefault {
|
||||
t.Error("Expected at least one host to be marked as default")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUserInfo(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
authenticated bool
|
||||
username string
|
||||
authTime time.Time
|
||||
}{
|
||||
{
|
||||
name: "authenticated user",
|
||||
authenticated: true,
|
||||
username: "john.doe@example.com",
|
||||
authTime: time.Now(),
|
||||
},
|
||||
{
|
||||
name: "unauthenticated user",
|
||||
authenticated: false,
|
||||
username: "",
|
||||
authTime: time.Time{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create handler
|
||||
handler := &Handler{}
|
||||
|
||||
// Create request
|
||||
req := httptest.NewRequest("GET", "/api/v1/user", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Set identity context
|
||||
user := identity.NewUser()
|
||||
if tt.authenticated {
|
||||
user.SetUserName(tt.username)
|
||||
user.SetAuthenticated(true)
|
||||
user.SetAuthTime(tt.authTime)
|
||||
}
|
||||
req = identity.AddToRequestCtx(user, req)
|
||||
|
||||
// Call handler
|
||||
handler.HandleUserInfo(w, req)
|
||||
|
||||
if !tt.authenticated {
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, w.Code)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check response
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
var userInfo UserInfo
|
||||
err := json.Unmarshal(w.Body.Bytes(), &userInfo)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if userInfo.Username != tt.username {
|
||||
t.Errorf("Expected username %s, got %s", tt.username, userInfo.Username)
|
||||
}
|
||||
|
||||
if userInfo.Authenticated != tt.authenticated {
|
||||
t.Errorf("Expected authenticated %v, got %v", tt.authenticated, userInfo.Authenticated)
|
||||
}
|
||||
|
||||
if tt.authenticated && userInfo.AuthTime.IsZero() {
|
||||
t.Error("Expected non-zero auth time for authenticated user")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWebInterface(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
authenticated bool
|
||||
expectStatus int
|
||||
expectContent string
|
||||
}{
|
||||
{
|
||||
name: "authenticated user",
|
||||
authenticated: true,
|
||||
expectStatus: http.StatusOK,
|
||||
expectContent: "RDP Gateway",
|
||||
},
|
||||
{
|
||||
name: "unauthenticated user",
|
||||
authenticated: false,
|
||||
expectStatus: http.StatusFound,
|
||||
expectContent: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create handler with minimal configuration
|
||||
handler := &Handler{
|
||||
templatesPath: "./templates",
|
||||
}
|
||||
handler.loadWebConfig()
|
||||
handler.loadHTMLTemplate()
|
||||
|
||||
// Create request
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Set identity context
|
||||
user := identity.NewUser()
|
||||
if tt.authenticated {
|
||||
user.SetUserName("testuser")
|
||||
user.SetAuthenticated(true)
|
||||
user.SetAuthTime(time.Now())
|
||||
}
|
||||
req = identity.AddToRequestCtx(user, req)
|
||||
|
||||
// Call handler
|
||||
handler.HandleWebInterface(w, req)
|
||||
|
||||
// Check response
|
||||
if w.Code != tt.expectStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectStatus, w.Code)
|
||||
}
|
||||
|
||||
if tt.authenticated {
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, tt.expectContent) {
|
||||
t.Errorf("Expected response to contain %s", tt.expectContent)
|
||||
}
|
||||
|
||||
// Check that it's a complete HTML document
|
||||
if !strings.Contains(body, "<!DOCTYPE html>") {
|
||||
t.Error("Expected complete HTML document")
|
||||
}
|
||||
|
||||
// Check for key elements (using fallback template)
|
||||
expectedElements := []string{
|
||||
"serversGrid",
|
||||
"connectButton",
|
||||
"loadServers",
|
||||
"connectToServer",
|
||||
}
|
||||
|
||||
for _, element := range expectedElements {
|
||||
if !strings.Contains(body, element) {
|
||||
t.Errorf("Expected HTML to contain %s", element)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Check redirect location
|
||||
location := w.Header().Get("Location")
|
||||
if location != "/connect" {
|
||||
t.Errorf("Expected redirect to /connect, got %s", location)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostSelectionIntegration(t *testing.T) {
|
||||
// Test the full flow from host selection to RDP download
|
||||
tests := []struct {
|
||||
name string
|
||||
hostSelection string
|
||||
hosts []string
|
||||
queryParams string
|
||||
expectHost string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "roundrobin selection",
|
||||
hostSelection: "roundrobin",
|
||||
hosts: []string{"host1.com", "host2.com", "host3.com"},
|
||||
queryParams: "",
|
||||
expectHost: "", // Will be one of the hosts
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "unsigned specific host",
|
||||
hostSelection: "unsigned",
|
||||
hosts: []string{"host1.com", "host2.com"},
|
||||
queryParams: "?host=host2.com",
|
||||
expectHost: "host2.com",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "unsigned invalid host",
|
||||
hostSelection: "unsigned",
|
||||
hosts: []string{"host1.com", "host2.com"},
|
||||
queryParams: "?host=invalid.com",
|
||||
expectHost: "",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "any host allowed",
|
||||
hostSelection: "any",
|
||||
hosts: []string{"host1.com"},
|
||||
queryParams: "?host=any-host.com",
|
||||
expectHost: "any-host.com",
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create handler
|
||||
handler := &Handler{
|
||||
hostSelection: tt.hostSelection,
|
||||
hosts: tt.hosts,
|
||||
gatewayAddress: &url.URL{Host: "gateway.example.com"},
|
||||
}
|
||||
|
||||
// Create request for RDP download
|
||||
req := httptest.NewRequest("GET", "/connect"+tt.queryParams, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Set authenticated user
|
||||
user := identity.NewUser()
|
||||
user.SetUserName("testuser")
|
||||
user.SetAuthenticated(true)
|
||||
user.SetAuthTime(time.Now())
|
||||
req = identity.AddToRequestCtx(user, req)
|
||||
|
||||
// Mock the token generator to avoid errors
|
||||
handler.paaTokenGenerator = func(ctx context.Context, user, host string) (string, error) {
|
||||
return "mock-token", nil
|
||||
}
|
||||
|
||||
// Call download handler
|
||||
handler.HandleDownload(w, req)
|
||||
|
||||
if tt.expectError {
|
||||
if w.Code == http.StatusOK {
|
||||
t.Error("Expected error but got success")
|
||||
}
|
||||
} else {
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status %d, got %d. Body: %s", http.StatusOK, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Check content type
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if contentType != "application/x-rdp" {
|
||||
t.Errorf("Expected Content-Type application/x-rdp, got %s", contentType)
|
||||
}
|
||||
|
||||
// Check content disposition
|
||||
disposition := w.Header().Get("Content-Disposition")
|
||||
if !strings.Contains(disposition, "attachment") || !strings.Contains(disposition, ".rdp") {
|
||||
t.Errorf("Expected attachment disposition with .rdp file, got %s", disposition)
|
||||
}
|
||||
|
||||
// Check RDP content for expected host
|
||||
body := w.Body.String()
|
||||
if tt.expectHost != "" {
|
||||
if !strings.Contains(body, tt.expectHost) {
|
||||
t.Errorf("Expected RDP content to contain host %s", tt.expectHost)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for gateway configuration
|
||||
if !strings.Contains(body, "gateway.example.com") {
|
||||
t.Error("Expected RDP content to contain gateway address")
|
||||
}
|
||||
|
||||
if !strings.Contains(body, "mock-token") {
|
||||
t.Error("Expected RDP content to contain access token")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,13 @@
|
||||
# builder stage
|
||||
FROM golang:1.24-alpine as builder
|
||||
|
||||
#RUN apt-get update && apt-get install -y libpam-dev
|
||||
# Install CA certificates explicitly in builder
|
||||
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
|
||||
# certificate generation
|
||||
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 && \
|
||||
@ -29,21 +29,24 @@ RUN git clone https://github.com/bolkedebruin/rdpgw.git /app && \
|
||||
chmod u+s /opt/rdpgw/rdpgw-auth
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk --no-cache add linux-pam musl tzdata
|
||||
# Install CA certificates in final stage
|
||||
RUN apk --no-cache add linux-pam musl tzdata ca-certificates && update-ca-certificates
|
||||
|
||||
# make tempdir in case filestore is used
|
||||
ADD tmp.tar /
|
||||
|
||||
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
|
||||
|
||||
# Copy templates directory
|
||||
COPY --from=builder /app/cmd/rdpgw/templates /opt/rdpgw/templates
|
||||
|
||||
# Copy assets directory from the app source
|
||||
COPY --chown=1001 --from=builder /app/assets /opt/rdpgw/assets
|
||||
|
||||
USER 0
|
||||
|
||||
WORKDIR /opt/rdpgw
|
||||
ENTRYPOINT ["/bin/sh", "/run.sh"]
|
||||
|
||||
221
docs/header-authentication.md
Normal file
221
docs/header-authentication.md
Normal file
@ -0,0 +1,221 @@
|
||||
# Header Authentication
|
||||
|
||||
RDPGW supports header-based authentication for integration with reverse proxy services that handle authentication upstream.
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
Server:
|
||||
Authentication:
|
||||
- header
|
||||
Tls: disable # Proxy handles TLS termination
|
||||
|
||||
Header:
|
||||
UserHeader: "X-Forwarded-User" # Required: Username header
|
||||
UserIdHeader: "X-Forwarded-User-Id" # Optional: User ID header
|
||||
EmailHeader: "X-Forwarded-Email" # Optional: Email header
|
||||
DisplayNameHeader: "X-Forwarded-Name" # Optional: Display name header
|
||||
|
||||
Caps:
|
||||
TokenAuth: true
|
||||
|
||||
Security:
|
||||
VerifyClientIp: false # Requests come through proxy
|
||||
```
|
||||
|
||||
## Proxy Service Examples
|
||||
|
||||
### Microsoft Azure Application Proxy
|
||||
|
||||
```yaml
|
||||
Server:
|
||||
Authentication:
|
||||
- header
|
||||
Tls: disable # App Proxy handles TLS termination
|
||||
|
||||
Header:
|
||||
UserHeader: "X-MS-CLIENT-PRINCIPAL-NAME"
|
||||
UserIdHeader: "X-MS-CLIENT-PRINCIPAL-ID"
|
||||
EmailHeader: "X-MS-CLIENT-PRINCIPAL-EMAIL"
|
||||
|
||||
Security:
|
||||
VerifyClientIp: false # Required for App Proxy
|
||||
|
||||
Caps:
|
||||
TokenAuth: true # Essential for RDP client connections
|
||||
```
|
||||
|
||||
**Azure Configuration:**
|
||||
|
||||
1. **Create App Registration** in Azure AD:
|
||||
```bash
|
||||
# Note the Application ID for App Proxy configuration
|
||||
az ad app create --display-name "RDPGW-AppProxy"
|
||||
```
|
||||
|
||||
2. **Configure Application Proxy**:
|
||||
- **Internal URL**: `http://rdpgw-internal:80` (or your internal RDPGW address)
|
||||
- **External URL**: `https://rdpgw.yourdomain.com`
|
||||
- **Pre-authentication**: Azure Active Directory
|
||||
- **Pass through**: Enabled for `/remoteDesktopGateway/`
|
||||
|
||||
3. **Configure Conditional Access Policies**:
|
||||
- Target the RDPGW App Proxy application
|
||||
- Set device compliance, location restrictions, MFA requirements
|
||||
- Enable session controls as needed
|
||||
|
||||
**Important App Proxy Configuration:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "RDPGW",
|
||||
"internalUrl": "http://rdpgw-internal",
|
||||
"externalUrl": "https://rdpgw.yourdomain.com",
|
||||
"preAuthenticatedApplication": {
|
||||
"preAuthenticationType": "AzureActiveDirectory",
|
||||
"passthroughPaths": [
|
||||
"/remoteDesktopGateway/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Authentication Flow:**
|
||||
|
||||
1. **Web Authentication** (`/connect` endpoint):
|
||||
```
|
||||
User Browser → App Proxy (Azure AD auth) → RDPGW → Downloads RDP file
|
||||
```
|
||||
|
||||
2. **RDP Client Connection** (`/remoteDesktopGateway/` endpoint):
|
||||
```
|
||||
RDP Client → App Proxy (passthrough) → RDPGW (token validation) → RDP Host
|
||||
```
|
||||
|
||||
**Key Requirements:**
|
||||
- **Passthrough configuration** for `/remoteDesktopGateway/` path
|
||||
- **Header authentication** only for `/connect` endpoint
|
||||
- **Token-based auth** for actual RDP connections
|
||||
- **Disable IP verification** due to App Proxy NAT
|
||||
|
||||
### Google Cloud Identity-Aware Proxy (IAP)
|
||||
|
||||
```yaml
|
||||
Header:
|
||||
UserHeader: "X-Goog-Authenticated-User-Email"
|
||||
UserIdHeader: "X-Goog-Authenticated-User-ID"
|
||||
EmailHeader: "X-Goog-Authenticated-User-Email"
|
||||
```
|
||||
|
||||
**Setup**: Enable IAP on your Cloud Load Balancer pointing to RDPGW. Configure OAuth consent screen and authorized users/groups.
|
||||
|
||||
### AWS Application Load Balancer (ALB) with Cognito
|
||||
|
||||
```yaml
|
||||
Header:
|
||||
UserHeader: "X-Amzn-Oidc-Subject"
|
||||
EmailHeader: "X-Amzn-Oidc-Email"
|
||||
DisplayNameHeader: "X-Amzn-Oidc-Name"
|
||||
```
|
||||
|
||||
**Setup**: Configure ALB with Cognito User Pool authentication. Enable OIDC headers forwarding to RDPGW target group.
|
||||
|
||||
### Traefik with ForwardAuth
|
||||
|
||||
```yaml
|
||||
Header:
|
||||
UserHeader: "X-Forwarded-User"
|
||||
EmailHeader: "X-Forwarded-Email"
|
||||
DisplayNameHeader: "X-Forwarded-Name"
|
||||
```
|
||||
|
||||
**Setup**: Use Traefik ForwardAuth middleware with external auth service (e.g., OAuth2 Proxy, Authelia) that sets headers.
|
||||
|
||||
### nginx with auth_request
|
||||
|
||||
```yaml
|
||||
Header:
|
||||
UserHeader: "X-Auth-User"
|
||||
EmailHeader: "X-Auth-Email"
|
||||
```
|
||||
|
||||
**nginx config**:
|
||||
```nginx
|
||||
upstream rdpgw {
|
||||
server rdpgw:443;
|
||||
}
|
||||
|
||||
upstream auth-service {
|
||||
server auth-service:80;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name your-gateway.example.com;
|
||||
|
||||
# SSL configuration
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
# Auth endpoint (internal)
|
||||
location /auth {
|
||||
internal;
|
||||
proxy_pass http://auth-service;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header X-Original-URI $request_uri;
|
||||
proxy_set_header X-Original-Method $request_method;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Main location with auth and WebSocket support
|
||||
location / {
|
||||
# Authentication
|
||||
auth_request /auth;
|
||||
auth_request_set $user $upstream_http_x_auth_user;
|
||||
auth_request_set $email $upstream_http_x_auth_email;
|
||||
|
||||
# Forward user headers to RDPGW
|
||||
proxy_set_header X-Auth-User $user;
|
||||
proxy_set_header X-Auth-Email $email;
|
||||
|
||||
# WebSocket and HTTP upgrade support
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Timeouts for long-lived connections
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
|
||||
# Disable buffering for real-time protocols
|
||||
proxy_buffering off;
|
||||
|
||||
proxy_pass https://rdpgw;
|
||||
}
|
||||
}
|
||||
|
||||
# WebSocket upgrade mapping
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Trust Boundary**: RDPGW trusts headers set by the proxy. Ensure the proxy cannot be bypassed.
|
||||
- **Header Validation**: Configure proxy to strip/override user headers from client requests.
|
||||
- **Network Security**: Deploy RDPGW in private network accessible only via the proxy.
|
||||
- **TLS**: Enable TLS between proxy and RDPGW in production environments.
|
||||
|
||||
## Validation
|
||||
|
||||
Test header authentication:
|
||||
```bash
|
||||
curl -H "X-Forwarded-User: testuser@domain.com" \
|
||||
https://your-proxy/connect
|
||||
```
|
||||
156
docs/kerberos-authentication.md
Normal file
156
docs/kerberos-authentication.md
Normal file
@ -0,0 +1,156 @@
|
||||
# Kerberos Authentication
|
||||
|
||||

|
||||
|
||||
RDPGW supports Kerberos authentication via SPNEGO for seamless integration with Active Directory and other Kerberos environments.
|
||||
|
||||
## Important Notes
|
||||
|
||||
**⚠️ DNS Requirements**: Kerberos is heavily reliant on DNS (forward and reverse). Ensure your DNS is properly configured.
|
||||
|
||||
**⚠️ Error Messages**: Kerberos errors are not always descriptive. This documentation provides configuration guidance, but detailed Kerberos troubleshooting is beyond scope.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Valid Kerberos environment (KDC/Active Directory)
|
||||
- Proper DNS configuration (forward and reverse lookups)
|
||||
- Service principal for the gateway
|
||||
- Keytab file with appropriate permissions
|
||||
|
||||
## Configuration
|
||||
|
||||
### 1. Create Service Principal
|
||||
|
||||
Create a service principal for the gateway in your Kerberos realm:
|
||||
|
||||
```bash
|
||||
# Active Directory
|
||||
setspn -A HTTP/rdpgw.example.com@YOUR.REALM service-account
|
||||
|
||||
# MIT Kerberos
|
||||
kadmin.local -q "addprinc -randkey HTTP/rdpgw.example.com@YOUR.REALM"
|
||||
```
|
||||
|
||||
### 2. Generate Keytab
|
||||
|
||||
Use `ktutil` or similar tool to create a keytab file:
|
||||
|
||||
```bash
|
||||
ktutil
|
||||
addent -password -p HTTP/rdpgw.example.com@YOUR.REALM -k 1 -e aes256-cts-hmac-sha1-96
|
||||
wkt rdpgw.keytab
|
||||
quit
|
||||
```
|
||||
|
||||
Place the keytab file in a secure location and ensure it's only readable by the gateway user:
|
||||
|
||||
```bash
|
||||
sudo mv rdpgw.keytab /etc/keytabs/
|
||||
sudo chown rdpgw:rdpgw /etc/keytabs/rdpgw.keytab
|
||||
sudo chmod 600 /etc/keytabs/rdpgw.keytab
|
||||
```
|
||||
|
||||
### 3. Configure krb5.conf
|
||||
|
||||
Ensure `/etc/krb5.conf` is properly configured:
|
||||
|
||||
```ini
|
||||
[libdefaults]
|
||||
default_realm = YOUR.REALM
|
||||
dns_lookup_realm = true
|
||||
dns_lookup_kdc = true
|
||||
|
||||
[realms]
|
||||
YOUR.REALM = {
|
||||
kdc = kdc.your.realm:88
|
||||
admin_server = kdc.your.realm:749
|
||||
}
|
||||
|
||||
[domain_realm]
|
||||
.your.realm = YOUR.REALM
|
||||
your.realm = YOUR.REALM
|
||||
```
|
||||
|
||||
### 4. Gateway Configuration
|
||||
|
||||
```yaml
|
||||
Server:
|
||||
Authentication:
|
||||
- kerberos
|
||||
Kerberos:
|
||||
Keytab: /etc/keytabs/rdpgw.keytab
|
||||
Krb5conf: /etc/krb5.conf
|
||||
Caps:
|
||||
TokenAuth: false
|
||||
```
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
1. Client connects to gateway with Kerberos ticket
|
||||
2. Gateway validates ticket using keytab
|
||||
3. Client connects directly without RDP file download
|
||||
4. Gateway proxies TGT requests to KDC as needed
|
||||
|
||||
## KDC Proxy Support
|
||||
|
||||
RDPGW includes KDC proxy functionality for environments where clients cannot directly reach the KDC:
|
||||
|
||||
- Endpoint: `https://your-gateway/KdcProxy`
|
||||
- Supports MS-KKDCP protocol
|
||||
- Automatically configured when Kerberos authentication is enabled
|
||||
|
||||
## Client Configuration
|
||||
|
||||
### Windows Clients
|
||||
|
||||
Configure Windows clients to use the gateway's FQDN and ensure:
|
||||
- Client can resolve gateway hostname
|
||||
- Client time is synchronized with KDC
|
||||
- Client has valid TGT
|
||||
|
||||
### Linux Clients
|
||||
|
||||
Ensure `krb5.conf` is configured and client has valid ticket:
|
||||
|
||||
```bash
|
||||
kinit username@YOUR.REALM
|
||||
klist # Verify ticket
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Clock Skew**: Ensure all systems have synchronized time
|
||||
2. **DNS Issues**: Verify forward/reverse DNS resolution
|
||||
3. **Principal Names**: Ensure service principal matches gateway FQDN
|
||||
4. **Keytab Permissions**: Verify keytab file permissions and ownership
|
||||
|
||||
### Debug Commands
|
||||
|
||||
```bash
|
||||
# Test keytab
|
||||
kinit -k -t /etc/keytabs/rdpgw.keytab HTTP/rdpgw.example.com@YOUR.REALM
|
||||
|
||||
# Verify DNS
|
||||
nslookup rdpgw.example.com
|
||||
nslookup <gateway-ip>
|
||||
|
||||
# Check time sync
|
||||
ntpdate -q ntp.your.realm
|
||||
```
|
||||
|
||||
### Log Analysis
|
||||
|
||||
Enable verbose logging in RDPGW and check for:
|
||||
- Keytab loading errors
|
||||
- Principal validation failures
|
||||
- KDC communication issues
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Protect keytab files with appropriate permissions (600)
|
||||
- Regularly rotate service account passwords
|
||||
- Monitor for unusual authentication patterns
|
||||
- Ensure encrypted communication (aes256-cts-hmac-sha1-96)
|
||||
- Use specific service accounts, not user accounts
|
||||
258
docs/ms-app-proxy-deployment.md
Normal file
258
docs/ms-app-proxy-deployment.md
Normal file
@ -0,0 +1,258 @@
|
||||
# Microsoft Azure Application Proxy Deployment Guide
|
||||
|
||||
This guide provides step-by-step instructions for deploying RDPGW behind Microsoft Azure Application Proxy with Conditional Access Policy enforcement.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
Internet → Azure AD (Auth + CAP) → App Proxy → RDPGW (Internal) → RDP Hosts
|
||||
```
|
||||
|
||||
**Authentication Flow:**
|
||||
- **Web requests** (`/connect`): Full Azure AD authentication with headers
|
||||
- **RDP protocol** (`/remoteDesktopGateway/`): Passthrough with token validation
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Azure AD Premium P1/P2 (for Conditional Access)
|
||||
- Azure AD Application Proxy connector installed
|
||||
- RDPGW deployed internally
|
||||
- Network connectivity from connector to RDPGW
|
||||
|
||||
## Step 1: Azure AD App Registration
|
||||
|
||||
```powershell
|
||||
# Create app registration
|
||||
$app = New-AzADApplication -DisplayName "RDPGW-AppProxy" `
|
||||
-HomePage "https://rdpgw.yourdomain.com" `
|
||||
-IdentifierUris "https://rdpgw.yourdomain.com"
|
||||
|
||||
# Note the Application ID
|
||||
Write-Host "Application ID: $($app.ApplicationId)"
|
||||
```
|
||||
|
||||
## Step 2: Configure Application Proxy
|
||||
|
||||
### Portal Configuration
|
||||
|
||||
1. **Navigate to**: Azure AD → Enterprise Applications → New Application
|
||||
2. **Select**: On-premises application
|
||||
3. **Configure**:
|
||||
- **Name**: RDPGW
|
||||
- **Internal URL**: `http://rdpgw-server:80`
|
||||
- **External URL**: `https://rdpgw.yourdomain.com`
|
||||
- **Pre-authentication**: Azure Active Directory
|
||||
- **Connector Group**: Select appropriate connector
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"application": {
|
||||
"name": "RDPGW",
|
||||
"internalUrl": "http://rdpgw-server",
|
||||
"externalUrl": "https://rdpgw.yourdomain.com",
|
||||
"preAuthentication": "aadPreAuthentication",
|
||||
"externalAuthenticationType": "aadPreAuthentication",
|
||||
"applicationProxyUrlSettings": {
|
||||
"externalUrl": "https://rdpgw.yourdomain.com",
|
||||
"internalUrl": "http://rdpgw-server",
|
||||
"isTranslateHostHeaderEnabled": true,
|
||||
"isTranslateLinksInBodyEnabled": false,
|
||||
"isOnPremPublishingEnabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 3: Configure Passthrough for RDP Endpoint
|
||||
|
||||
**Critical**: Configure App Proxy to bypass authentication for RDP connections:
|
||||
|
||||
### PowerShell Configuration
|
||||
|
||||
```powershell
|
||||
# Get the application
|
||||
$app = Get-AzADApplication -DisplayName "RDPGW-AppProxy"
|
||||
|
||||
# Configure passthrough paths (if available via API)
|
||||
# Note: This may need to be configured via Support ticket
|
||||
$passthroughPaths = @("/remoteDesktopGateway/*")
|
||||
```
|
||||
|
||||
### Support Request
|
||||
|
||||
If passthrough configuration isn't available in portal:
|
||||
|
||||
1. **Open Azure Support Ticket**
|
||||
2. **Request**: Passthrough configuration for `/remoteDesktopGateway/*` path
|
||||
3. **Provide**: Application ID and external URL
|
||||
4. **Reason**: RDP client compatibility requirements
|
||||
|
||||
## Step 4: RDPGW Configuration
|
||||
|
||||
### Complete Configuration File
|
||||
|
||||
```yaml
|
||||
# rdpgw.yaml
|
||||
Server:
|
||||
Authentication:
|
||||
- header
|
||||
Tls: disable
|
||||
GatewayAddress: https://rdpgw.yourdomain.com
|
||||
Port: 80
|
||||
Hosts:
|
||||
- server1.internal.domain:3389
|
||||
- server2.internal.domain:3389
|
||||
- "{{ preferred_username }}-desktop:3389" # Dynamic host mapping
|
||||
|
||||
Header:
|
||||
UserHeader: "X-MS-CLIENT-PRINCIPAL-NAME"
|
||||
UserIdHeader: "X-MS-CLIENT-PRINCIPAL-ID"
|
||||
EmailHeader: "X-MS-CLIENT-PRINCIPAL-EMAIL"
|
||||
|
||||
Security:
|
||||
VerifyClientIp: false
|
||||
PAATokenSigningKey: "your-32-character-signing-key-here"
|
||||
PAATokenEncryptionKey: "your-32-character-encryption-key"
|
||||
|
||||
Caps:
|
||||
TokenAuth: true
|
||||
IdleTimeout: 60
|
||||
|
||||
Client:
|
||||
UsernameTemplate: "{{ username }}\x1f{{ token }}"
|
||||
```
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
rdpgw:
|
||||
image: bolkedebruin/rdpgw:latest
|
||||
ports:
|
||||
- "80:443"
|
||||
volumes:
|
||||
- ./rdpgw.yaml:/app/rdpgw.yaml:ro
|
||||
environment:
|
||||
- RDPGW_SERVER__TLS=disable
|
||||
- RDPGW_SERVER__PORT=443
|
||||
networks:
|
||||
- internal
|
||||
|
||||
networks:
|
||||
internal:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
## Step 5: Conditional Access Policy
|
||||
|
||||
### Create CAP for RDPGW
|
||||
|
||||
```powershell
|
||||
# PowerShell example (simplified)
|
||||
$conditions = @{
|
||||
"applications" = @{
|
||||
"includeApplications" = @($app.ApplicationId)
|
||||
}
|
||||
"users" = @{
|
||||
"includeGroups" = @("rdp-users-group-id")
|
||||
}
|
||||
"locations" = @{
|
||||
"includeLocations" = @("AllTrusted")
|
||||
}
|
||||
}
|
||||
|
||||
$grantControls = @{
|
||||
"operator" = "OR"
|
||||
"builtInControls" = @("mfa", "compliantDevice")
|
||||
}
|
||||
```
|
||||
|
||||
### Portal Configuration
|
||||
|
||||
1. **Navigate to**: Azure AD → Security → Conditional Access
|
||||
2. **Create Policy**:
|
||||
- **Name**: RDPGW Access Control
|
||||
- **Users**: Select appropriate groups
|
||||
- **Cloud apps**: Select RDPGW application
|
||||
- **Conditions**: Configure as needed (device, location, etc.)
|
||||
- **Grant**: Require MFA + Compliant Device
|
||||
- **Session**: Configure session lifetime
|
||||
|
||||
## Step 6: Testing
|
||||
|
||||
### Test Web Authentication
|
||||
|
||||
```bash
|
||||
# Test /connect endpoint
|
||||
curl -v https://rdpgw.yourdomain.com/connect
|
||||
# Should redirect to Azure AD login
|
||||
```
|
||||
|
||||
### Test RDP Connection
|
||||
|
||||
1. **Access web interface**: `https://rdpgw.yourdomain.com/connect`
|
||||
2. **Authenticate**: Complete Azure AD login + MFA
|
||||
3. **Download RDP file**: Should contain token-based credentials
|
||||
4. **Connect via RDP client**: Should work without additional authentication
|
||||
|
||||
### Verify Headers
|
||||
|
||||
Check that App Proxy forwards correct headers:
|
||||
|
||||
```bash
|
||||
# From internal network, test RDPGW directly
|
||||
curl -H "X-MS-CLIENT-PRINCIPAL-NAME: user@domain.com" \
|
||||
http://rdpgw-server/connect
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **RDP Client Won't Connect**:
|
||||
- Verify passthrough configuration for `/remoteDesktopGateway/*`
|
||||
- Check token generation in downloaded RDP file
|
||||
- Ensure `TokenAuth: true` in configuration
|
||||
|
||||
2. **Authentication Loop**:
|
||||
- Verify header configuration matches App Proxy headers
|
||||
- Check `VerifyClientIp: false` setting
|
||||
- Validate App Proxy connector connectivity
|
||||
|
||||
3. **CAP Not Enforced**:
|
||||
- Verify policy targets correct application
|
||||
- Check user/group assignments
|
||||
- Review conditional access logs
|
||||
|
||||
### Debug Commands
|
||||
|
||||
```bash
|
||||
# Check RDPGW logs
|
||||
docker logs rdpgw-container
|
||||
|
||||
# Test internal connectivity
|
||||
curl -H "X-MS-CLIENT-PRINCIPAL-NAME: test@domain.com" \
|
||||
http://rdpgw-internal/connect
|
||||
|
||||
# Verify token generation
|
||||
curl -v https://rdpgw.yourdomain.com/connect
|
||||
```
|
||||
|
||||
### Azure AD Logs
|
||||
|
||||
Monitor these logs for authentication issues:
|
||||
|
||||
- **Sign-ins**: User authentication events
|
||||
- **Conditional Access**: Policy evaluation results
|
||||
- **Application Proxy**: Connector and application events
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Network Isolation**: Deploy RDPGW in private network
|
||||
- **Connector Security**: Ensure App Proxy connector is secured
|
||||
- **Token Validation**: Monitor for token replay attacks
|
||||
- **Audit Logging**: Enable comprehensive logging for compliance
|
||||
- **Certificate Management**: Ensure proper TLS certificate chain
|
||||
268
docs/ntlm-authentication.md
Normal file
268
docs/ntlm-authentication.md
Normal file
@ -0,0 +1,268 @@
|
||||
# NTLM Authentication
|
||||
|
||||
RDPGW supports NTLM authentication for simple setup with Windows clients, particularly useful for small deployments with a limited number of users.
|
||||
|
||||
## Advantages
|
||||
|
||||
- **Easy Setup**: Simple configuration without external dependencies
|
||||
- **Windows Client Support**: Works with default Windows client `mstsc`
|
||||
- **No External Services**: Self-contained authentication mechanism
|
||||
- **Quick Deployment**: Ideal for small teams or testing environments
|
||||
|
||||
## Security Warning
|
||||
|
||||
**⚠️ Plain Text Storage**: Passwords are currently stored in plain text to support the NTLM authentication protocol. Keep configuration files secure and avoid reusing passwords for other applications.
|
||||
|
||||
## Configuration
|
||||
|
||||
### 1. Gateway Configuration
|
||||
|
||||
Configure RDPGW to use NTLM authentication:
|
||||
|
||||
```yaml
|
||||
Server:
|
||||
Authentication:
|
||||
- ntlm
|
||||
Caps:
|
||||
TokenAuth: false
|
||||
```
|
||||
|
||||
### 2. Authentication Helper Configuration
|
||||
|
||||
Create configuration file for `rdpgw-auth` with user credentials:
|
||||
|
||||
```yaml
|
||||
# /etc/rdpgw-auth.yaml
|
||||
Users:
|
||||
- Username: "alice"
|
||||
Password: "secure_password_1"
|
||||
- Username: "bob"
|
||||
Password: "secure_password_2"
|
||||
- Username: "admin"
|
||||
Password: "admin_secure_password"
|
||||
```
|
||||
|
||||
### 3. Start Authentication Helper
|
||||
|
||||
Run the `rdpgw-auth` helper with NTLM configuration:
|
||||
|
||||
```bash
|
||||
./rdpgw-auth -c /etc/rdpgw-auth.yaml -s /tmp/rdpgw-auth.sock
|
||||
```
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
1. Client initiates NTLM handshake with gateway
|
||||
2. Gateway forwards NTLM messages to `rdpgw-auth`
|
||||
3. Helper validates credentials against configured user database
|
||||
4. Client connects directly on successful authentication
|
||||
|
||||
## User Management
|
||||
|
||||
### Adding Users
|
||||
|
||||
Edit the configuration file and restart the helper:
|
||||
|
||||
```yaml
|
||||
Users:
|
||||
- Username: "newuser"
|
||||
Password: "new_secure_password"
|
||||
- Username: "existing_user"
|
||||
Password: "existing_password"
|
||||
```
|
||||
|
||||
### Password Rotation
|
||||
|
||||
1. Update passwords in configuration file
|
||||
2. Restart `rdpgw-auth` helper
|
||||
3. Notify users of password changes
|
||||
|
||||
### User Removal
|
||||
|
||||
Remove user entries from configuration and restart helper.
|
||||
|
||||
## Deployment Options
|
||||
|
||||
### Systemd Service
|
||||
|
||||
Create `/etc/systemd/system/rdpgw-auth.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=RDPGW NTLM Authentication Helper
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=rdpgw
|
||||
ExecStart=/usr/local/bin/rdpgw-auth -c /etc/rdpgw-auth.yaml -s /tmp/rdpgw-auth.sock
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
rdpgw-auth:
|
||||
image: rdpgw-auth
|
||||
volumes:
|
||||
- ./rdpgw-auth.yaml:/etc/rdpgw-auth.yaml:ro
|
||||
- auth-socket:/tmp
|
||||
restart: always
|
||||
|
||||
rdpgw:
|
||||
image: rdpgw
|
||||
volumes:
|
||||
- auth-socket:/tmp
|
||||
depends_on:
|
||||
- rdpgw-auth
|
||||
|
||||
volumes:
|
||||
auth-socket:
|
||||
```
|
||||
|
||||
### Kubernetes Deployment
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: rdpgw-auth-config
|
||||
data:
|
||||
rdpgw-auth.yaml: |
|
||||
Users:
|
||||
- Username: "user1"
|
||||
Password: "password1"
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: rdpgw-auth
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: rdpgw-auth
|
||||
image: rdpgw-auth
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /etc/rdpgw-auth.yaml
|
||||
subPath: rdpgw-auth.yaml
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: rdpgw-auth-config
|
||||
```
|
||||
|
||||
## Client Configuration
|
||||
|
||||
### Windows (mstsc)
|
||||
|
||||
NTLM authentication works seamlessly with the default Windows Remote Desktop client:
|
||||
|
||||
1. Configure gateway address in RDP settings
|
||||
2. Save gateway credentials when prompted
|
||||
3. Connect using domain credentials or local accounts
|
||||
|
||||
### Alternative Clients
|
||||
|
||||
NTLM is widely supported across RDP clients:
|
||||
|
||||
- **mRemoteNG** (Windows)
|
||||
- **Royal TS/TSX** (Windows/macOS)
|
||||
- **Remmina** (Linux)
|
||||
- **FreeRDP** (Cross-platform)
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### File Permissions
|
||||
|
||||
Secure the configuration file:
|
||||
|
||||
```bash
|
||||
sudo chown rdpgw:rdpgw /etc/rdpgw-auth.yaml
|
||||
sudo chmod 600 /etc/rdpgw-auth.yaml
|
||||
```
|
||||
|
||||
### Password Policy
|
||||
|
||||
- Use strong, unique passwords for each user
|
||||
- Implement regular password rotation
|
||||
- Avoid reusing passwords from other systems
|
||||
- Consider minimum password length requirements
|
||||
|
||||
### Network Security
|
||||
|
||||
- Deploy gateway behind TLS termination
|
||||
- Use private networks when possible
|
||||
- Implement network-level access controls
|
||||
- Monitor authentication logs for suspicious activity
|
||||
|
||||
### Access Control
|
||||
|
||||
- Limit user accounts to necessary personnel only
|
||||
- Regularly audit user list and remove inactive accounts
|
||||
- Use principle of least privilege
|
||||
- Consider time-based access restrictions
|
||||
|
||||
## Migration Path
|
||||
|
||||
For production environments, consider migrating to more secure authentication methods:
|
||||
|
||||
### To OpenID Connect
|
||||
- Better password security (hashed storage)
|
||||
- MFA support
|
||||
- Centralized user management
|
||||
- SSO integration
|
||||
|
||||
### To Kerberos
|
||||
- No password storage in gateway
|
||||
- Enterprise authentication integration
|
||||
- Stronger cryptographic security
|
||||
- Seamless Windows domain integration
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Authentication Failed**: Verify username/password in configuration
|
||||
2. **Helper Not Running**: Check if `rdpgw-auth` process is active
|
||||
3. **Socket Errors**: Verify socket path and permissions
|
||||
|
||||
### Debug Commands
|
||||
|
||||
```bash
|
||||
# Check helper process
|
||||
ps aux | grep rdpgw-auth
|
||||
|
||||
# Verify configuration
|
||||
cat /etc/rdpgw-auth.yaml
|
||||
|
||||
# Test socket connectivity
|
||||
ls -la /tmp/rdpgw-auth.sock
|
||||
|
||||
# Monitor authentication logs
|
||||
journalctl -u rdpgw-auth -f
|
||||
```
|
||||
|
||||
### Log Analysis
|
||||
|
||||
Enable debug logging in `rdpgw-auth` for detailed NTLM protocol analysis:
|
||||
|
||||
```bash
|
||||
./rdpgw-auth -c /etc/rdpgw-auth.yaml -s /tmp/rdpgw-auth.sock -v
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Planned improvements for NTLM authentication:
|
||||
|
||||
- **Database Backend**: Support for SQLite/PostgreSQL user storage
|
||||
- **Password Hashing**: Secure password storage options
|
||||
- **Group Support**: Role-based access control
|
||||
- **Audit Logging**: Enhanced security monitoring
|
||||
75
docs/openid-authentication.md
Normal file
75
docs/openid-authentication.md
Normal file
@ -0,0 +1,75 @@
|
||||
# OpenID Connect Authentication
|
||||
|
||||

|
||||
|
||||
RDPGW supports OpenID Connect authentication for integration with identity providers like Keycloak, Okta, Google, Azure, Apple, or Facebook.
|
||||
|
||||
## Configuration
|
||||
|
||||
To use OpenID Connect, ensure you have properly configured your OpenID Connect provider with a client ID and secret. The client ID and secret authenticate the gateway to the OpenID Connect provider. The provider authenticates the user and provides the gateway with a token, which generates a PAA token for RDP host connections.
|
||||
|
||||
```yaml
|
||||
Server:
|
||||
Authentication:
|
||||
- openid
|
||||
OpenId:
|
||||
ProviderUrl: https://<provider_url>
|
||||
ClientId: <your_client_id>
|
||||
ClientSecret: <your_client_secret>
|
||||
Caps:
|
||||
TokenAuth: true
|
||||
```
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
1. User navigates to `https://your-gateway/connect`
|
||||
2. Gateway redirects to OpenID Connect provider for authentication
|
||||
3. User authenticates with the provider (supports MFA)
|
||||
4. Provider redirects back to gateway with authentication token
|
||||
5. Gateway validates token and generates RDP file with temporary credentials
|
||||
6. User downloads RDP file and connects using remote desktop client
|
||||
|
||||
## Multi-Factor Authentication (MFA)
|
||||
|
||||
RDPGW provides multi-factor authentication out of the box with OpenID Connect integration. Configure MFA in your identity provider to enhance security.
|
||||
|
||||
## Provider Examples
|
||||
|
||||
### Keycloak
|
||||
```yaml
|
||||
OpenId:
|
||||
ProviderUrl: https://keycloak.example.com/auth/realms/your-realm
|
||||
ClientId: rdpgw
|
||||
ClientSecret: your-keycloak-secret
|
||||
```
|
||||
|
||||
### Azure AD
|
||||
```yaml
|
||||
OpenId:
|
||||
ProviderUrl: https://login.microsoftonline.com/{tenant-id}/v2.0
|
||||
ClientId: your-azure-app-id
|
||||
ClientSecret: your-azure-secret
|
||||
```
|
||||
|
||||
### Google
|
||||
```yaml
|
||||
OpenId:
|
||||
ProviderUrl: https://accounts.google.com
|
||||
ClientId: your-google-client-id.googleusercontent.com
|
||||
ClientSecret: your-google-secret
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Always use HTTPS for production deployments
|
||||
- Store client secrets securely and rotate them regularly
|
||||
- Configure appropriate scopes and claims in your provider
|
||||
- Enable MFA in your identity provider for enhanced security
|
||||
- Set appropriate session timeouts in both gateway and provider
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Ensure `ProviderUrl` is accessible from the gateway
|
||||
- Verify redirect URI is configured in your provider (usually `https://your-gateway/callback`)
|
||||
- Check that required scopes (openid, profile, email) are configured
|
||||
- Validate that the provider's certificate is trusted by the gateway
|
||||
242
docs/pam-authentication.md
Normal file
242
docs/pam-authentication.md
Normal file
@ -0,0 +1,242 @@
|
||||
# PAM/Local Authentication
|
||||
|
||||

|
||||
|
||||
RDPGW supports PAM (Pluggable Authentication Modules) for authentication against local accounts, LDAP, Active Directory, and other PAM-supported systems.
|
||||
|
||||
## Important Notes
|
||||
|
||||
**⚠️ Client Limitation**: The default Windows client `mstsc` does not support basic authentication. Use alternative clients or switch to OpenID Connect, Kerberos, or NTLM authentication.
|
||||
|
||||
**⚠️ Container Considerations**: Using PAM for passwd authentication within containers is not recommended. Use OpenID Connect or Kerberos instead. For LDAP/AD authentication, PAM works well in containers.
|
||||
|
||||
## Architecture
|
||||
|
||||
PAM authentication uses a privilege separation model with the `rdpgw-auth` helper program:
|
||||
|
||||
- `rdpgw` - Main gateway (runs as unprivileged user)
|
||||
- `rdpgw-auth` - Authentication helper (runs as root or setuid)
|
||||
- Communication via Unix socket
|
||||
|
||||
## Configuration
|
||||
|
||||
### 1. PAM Service Configuration
|
||||
|
||||
Create `/etc/pam.d/rdpgw` for the authentication service:
|
||||
|
||||
**Local passwd authentication:**
|
||||
```plaintext
|
||||
auth required pam_unix.so
|
||||
account required pam_unix.so
|
||||
```
|
||||
|
||||
**LDAP authentication:**
|
||||
```plaintext
|
||||
auth required pam_ldap.so
|
||||
account required pam_ldap.so
|
||||
```
|
||||
|
||||
**Active Directory (via Winbind):**
|
||||
```plaintext
|
||||
auth sufficient pam_winbind.so
|
||||
account sufficient pam_winbind.so
|
||||
```
|
||||
|
||||
### 2. Gateway Configuration
|
||||
|
||||
```yaml
|
||||
Server:
|
||||
Authentication:
|
||||
- local
|
||||
AuthSocket: /tmp/rdpgw-auth.sock
|
||||
BasicAuthTimeout: 5 # seconds
|
||||
Caps:
|
||||
TokenAuth: false
|
||||
```
|
||||
|
||||
### 3. Start Authentication Helper
|
||||
|
||||
Run the `rdpgw-auth` helper program:
|
||||
|
||||
```bash
|
||||
# Basic usage
|
||||
./rdpgw-auth -n rdpgw -s /tmp/rdpgw-auth.sock
|
||||
|
||||
# With custom PAM service name
|
||||
./rdpgw-auth -n custom-service -s /tmp/rdpgw-auth.sock
|
||||
|
||||
# Run as systemd service
|
||||
systemctl start rdpgw-auth
|
||||
```
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
1. Client connects to gateway with username/password
|
||||
2. Gateway forwards credentials to `rdpgw-auth` via socket
|
||||
3. `rdpgw-auth` validates credentials using PAM
|
||||
4. Gateway generates session tokens on successful authentication
|
||||
5. Client connects directly using authenticated session
|
||||
|
||||
## PAM Module Examples
|
||||
|
||||
### LDAP Integration
|
||||
|
||||
Install and configure LDAP PAM module:
|
||||
|
||||
```bash
|
||||
# Install LDAP PAM module
|
||||
sudo apt-get install libpam-ldap
|
||||
|
||||
# Configure /etc/pam_ldap.conf
|
||||
host ldap.example.com
|
||||
base dc=example,dc=com
|
||||
binddn cn=readonly,dc=example,dc=com
|
||||
bindpw secret
|
||||
```
|
||||
|
||||
### Active Directory Integration
|
||||
|
||||
Configure Winbind PAM module:
|
||||
|
||||
```bash
|
||||
# Install Winbind
|
||||
sudo apt-get install winbind libpam-winbind
|
||||
|
||||
# Configure /etc/samba/smb.conf
|
||||
[global]
|
||||
security = ads
|
||||
realm = EXAMPLE.COM
|
||||
workgroup = EXAMPLE
|
||||
```
|
||||
|
||||
### Two-Factor Authentication
|
||||
|
||||
Integrate with TOTP/HOTP using pam_oath:
|
||||
|
||||
```plaintext
|
||||
auth required pam_oath.so usersfile=/etc/users.oath
|
||||
auth required pam_unix.so
|
||||
account required pam_unix.so
|
||||
```
|
||||
|
||||
## Container Deployment
|
||||
|
||||
### Option 1: External Helper
|
||||
|
||||
Run `rdpgw-auth` on the host and mount socket:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
rdpgw:
|
||||
image: rdpgw
|
||||
volumes:
|
||||
- /tmp/rdpgw-auth.sock:/tmp/rdpgw-auth.sock
|
||||
```
|
||||
|
||||
### Option 2: Privileged Container
|
||||
|
||||
Mount PAM configuration and user databases:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
rdpgw:
|
||||
image: rdpgw
|
||||
privileged: true
|
||||
volumes:
|
||||
- /etc/passwd:/etc/passwd:ro
|
||||
- /etc/shadow:/etc/shadow:ro
|
||||
- /etc/pam.d:/etc/pam.d:ro
|
||||
```
|
||||
|
||||
## Systemd Service
|
||||
|
||||
Create `/etc/systemd/system/rdpgw-auth.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=RDPGW Authentication Helper
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
ExecStart=/usr/local/bin/rdpgw-auth -n rdpgw -s /tmp/rdpgw-auth.sock
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Enable and start the service:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable rdpgw-auth
|
||||
sudo systemctl start rdpgw-auth
|
||||
```
|
||||
|
||||
## Compatible Clients
|
||||
|
||||
Since `mstsc` doesn't support basic authentication, use these alternatives:
|
||||
|
||||
### Windows
|
||||
- **Remote Desktop Connection Manager** (RDCMan)
|
||||
- **mRemoteNG**
|
||||
- **Royal TS/TSX**
|
||||
|
||||
### Linux
|
||||
- **Remmina**
|
||||
- **FreeRDP** (with basic auth support)
|
||||
- **KRDC**
|
||||
|
||||
### macOS
|
||||
- **Microsoft Remote Desktop** (from App Store)
|
||||
- **Royal TSX**
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Run `rdpgw-auth` with minimal privileges
|
||||
- Secure the Unix socket with appropriate permissions
|
||||
- Use strong PAM configurations (account lockout, password complexity)
|
||||
- Enable logging for authentication events
|
||||
- Consider rate limiting for brute force protection
|
||||
- Use encrypted connections (TLS) for the gateway
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Socket Permission Denied**: Check socket permissions and ownership
|
||||
2. **PAM Authentication Failed**: Verify PAM configuration and user credentials
|
||||
3. **Helper Not Running**: Ensure `rdpgw-auth` is running and accessible
|
||||
|
||||
### Debug Commands
|
||||
|
||||
```bash
|
||||
# Test PAM configuration
|
||||
pamtester rdpgw username authenticate
|
||||
|
||||
# Check socket
|
||||
ls -la /tmp/rdpgw-auth.sock
|
||||
|
||||
# Verify helper process
|
||||
ps aux | grep rdpgw-auth
|
||||
|
||||
# Test authentication manually
|
||||
echo "username:password" | nc -U /tmp/rdpgw-auth.sock
|
||||
```
|
||||
|
||||
### Log Analysis
|
||||
|
||||
Enable PAM logging in `/etc/rsyslog.conf`:
|
||||
|
||||
```plaintext
|
||||
auth,authpriv.* /var/log/auth.log
|
||||
```
|
||||
|
||||
Monitor authentication attempts:
|
||||
|
||||
```bash
|
||||
tail -f /var/log/auth.log | grep rdpgw
|
||||
```
|
||||
Loading…
Reference in New Issue
Block a user