Merge branch 'bolkedebruin:master' into anysigned

This commit is contained in:
Andrew Heberle 2025-10-03 19:46:31 +08:00 committed by GitHub
commit c1c752ebfb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 3482 additions and 141 deletions

148
README.md
View File

@ -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](docs/images/flow-openid.svg)
### 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
![Kerberos](docs/images/flow-kerberos.svg)
__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](docs/images/flow-pam.svg)
### 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
View 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
View 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

View File

@ -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 {

View 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")
}
})
}
}

View File

@ -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)
}
}

View 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
View 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();
}
});

View 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"
}
}

View 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>

View 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
View 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)
})
}

View 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)
}
}

View File

@ -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
}

View File

@ -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")
}
})
}

View File

@ -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)
}
}

View 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")
}
}
})
}
}

View File

@ -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"]

View 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
```

View File

@ -0,0 +1,156 @@
# Kerberos Authentication
![Kerberos](images/flow-kerberos.svg)
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

View 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
View 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

View File

@ -0,0 +1,75 @@
# OpenID Connect Authentication
![OpenID Connect](images/flow-openid.svg)
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
View File

@ -0,0 +1,242 @@
# PAM/Local Authentication
![PAM](images/flow-pam.svg)
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
```