diff --git a/README.md b/README.md index 07462be..3d1350a 100644 --- a/README.md +++ b/README.md @@ -328,6 +328,12 @@ Client: SplitUserDomain: false # If true, removes "username" (and "domain" if SplitUserDomain is true) from RDP file. # NoUsername: true + # If both SigningCert and SigningKey are set the downloaded RDP file will be signed + # so the client can authenticate the validity of the RDP file and reduce warnings from + # the client if the CA that issued the certificate is trusted. Both should be PEM encoded + # and the key must be an unencrypted RSA private key. + # SigningCert: /path/to/signing.crt + # SigningKey: /path/to/signing.key Security: # a random string of 32 characters to secure cookies on the client # make sure to share this amongst different pods diff --git a/cmd/rdpgw/config/configuration.go b/cmd/rdpgw/config/configuration.go index 37a71ef..2bbab3c 100644 --- a/cmd/rdpgw/config/configuration.go +++ b/cmd/rdpgw/config/configuration.go @@ -1,15 +1,16 @@ package config import ( + "log" + "os" + "strings" + "github.com/bolkedebruin/rdpgw/cmd/rdpgw/security" "github.com/knadh/koanf/parsers/yaml" "github.com/knadh/koanf/providers/confmap" "github.com/knadh/koanf/providers/env" "github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/v2" - "log" - "os" - "strings" ) const ( @@ -96,6 +97,8 @@ type ClientConfig struct { UsernameTemplate string `koanf:"usernametemplate"` SplitUserDomain bool `koanf:"splituserdomain"` NoUsername bool `koanf:"nousername"` + SigningCert string `koanf:"signingcert"` + SigningKey string `koanf:"signingkey"` } func ToCamel(s string) string { @@ -219,10 +222,10 @@ func Load(configFile string) Configuration { if Conf.Server.BasicAuthEnabled() && Conf.Server.Tls == "disable" { log.Fatalf("basicauth=local and tls=disable are mutually exclusive") } - + if Conf.Server.NtlmEnabled() && Conf.Server.KerberosEnabled() { log.Fatalf("ntlm and kerberos authentication are not stackable") - } + } if !Conf.Caps.TokenAuth && Conf.Server.OpenIDEnabled() { log.Fatalf("openid is configured but tokenauth disabled") @@ -238,7 +241,6 @@ func Load(configFile string) Configuration { } return Conf - } func (s *ServerConfig) OpenIDEnabled() bool { diff --git a/cmd/rdpgw/main.go b/cmd/rdpgw/main.go index 528106a..07e4e44 100644 --- a/cmd/rdpgw/main.go +++ b/cmd/rdpgw/main.go @@ -4,6 +4,12 @@ import ( "context" "crypto/tls" "fmt" + "log" + "net/http" + "net/url" + "os" + "strconv" + "github.com/bolkedebruin/gokrb5/v8/keytab" "github.com/bolkedebruin/gokrb5/v8/service" "github.com/bolkedebruin/gokrb5/v8/spnego" @@ -18,11 +24,6 @@ import ( "github.com/thought-machine/go-flags" "golang.org/x/crypto/acme/autocert" "golang.org/x/oauth2" - "log" - "net/http" - "net/url" - "os" - "strconv" ) const ( @@ -110,10 +111,12 @@ func main() { RdpOpts: web.RdpOpts{ UsernameTemplate: conf.Client.UsernameTemplate, SplitUserDomain: conf.Client.SplitUserDomain, - NoUsername: conf.Client.NoUsername, + NoUsername: conf.Client.NoUsername, }, GatewayAddress: url, TemplateFile: conf.Client.Defaults, + RdpSigningCert: conf.Client.SigningCert, + RdpSigningKey: conf.Client.SigningKey, } if conf.Caps.TokenAuth { @@ -229,7 +232,7 @@ func main() { // for stacking of authentication auth := web.NewAuthMux() rdp.MatcherFunc(web.NoAuthz).HandlerFunc(auth.SetAuthenticate) - + // ntlm if conf.Server.NtlmEnabled() { log.Printf("enabling NTLM authentication") @@ -238,7 +241,7 @@ func main() { rdp.NewRoute().HeadersRegexp("Authorization", "Negotiate").HandlerFunc(ntlm.NTLMAuth(gw.HandleGatewayProtocol)) auth.Register(`NTLM`) auth.Register(`Negotiate`) - } + } // basic auth if conf.Server.BasicAuthEnabled() { diff --git a/cmd/rdpgw/protocol/gateway.go b/cmd/rdpgw/protocol/gateway.go index 51fae1a..676387f 100644 --- a/cmd/rdpgw/protocol/gateway.go +++ b/cmd/rdpgw/protocol/gateway.go @@ -3,17 +3,18 @@ package protocol import ( "context" "errors" - "github.com/bolkedebruin/rdpgw/cmd/rdpgw/identity" - "github.com/bolkedebruin/rdpgw/cmd/rdpgw/transport" - "github.com/google/uuid" - "github.com/gorilla/websocket" - "github.com/patrickmn/go-cache" "log" "net" "net/http" "reflect" "syscall" "time" + + "github.com/bolkedebruin/rdpgw/cmd/rdpgw/identity" + "github.com/bolkedebruin/rdpgw/cmd/rdpgw/transport" + "github.com/google/uuid" + "github.com/gorilla/websocket" + "github.com/patrickmn/go-cache" ) const ( @@ -140,7 +141,7 @@ func (g *Gateway) setSendReceiveBuffers(conn net.Conn) error { if !ptrSysFd.IsValid() { return errors.New("cannot find Sysfd field") } - fd := int(ptrSysFd.Int()) + fd := int64ToFd(ptrSysFd.Int()) if g.ReceiveBuf > 0 { err := syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_RCVBUF, g.ReceiveBuf) diff --git a/cmd/rdpgw/protocol/gateway_others.go b/cmd/rdpgw/protocol/gateway_others.go new file mode 100644 index 0000000..e4204e8 --- /dev/null +++ b/cmd/rdpgw/protocol/gateway_others.go @@ -0,0 +1,8 @@ +//go:build !windows + +package protocol + +// the fd arg to syscall.SetsockoptInt on Linix is of type int +func int64ToFd(n int64) int { + return int(n) +} diff --git a/cmd/rdpgw/protocol/gateway_windows.go b/cmd/rdpgw/protocol/gateway_windows.go new file mode 100644 index 0000000..c0d02be --- /dev/null +++ b/cmd/rdpgw/protocol/gateway_windows.go @@ -0,0 +1,10 @@ +package protocol + +import ( + "syscall" +) + +// the fd arg to syscall.SetsockoptInt on Windows is of type syscall.Handle +func int64ToFd(n int64) syscall.Handle { + return syscall.Handle(n) +} diff --git a/cmd/rdpgw/web/oidc.go b/cmd/rdpgw/web/oidc.go index 861b3d5..cdc8c90 100644 --- a/cmd/rdpgw/web/oidc.go +++ b/cmd/rdpgw/web/oidc.go @@ -4,12 +4,13 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" + "net/http" + "time" + "github.com/bolkedebruin/rdpgw/cmd/rdpgw/identity" "github.com/coreos/go-oidc/v3/oidc" "github.com/patrickmn/go-cache" "golang.org/x/oauth2" - "net/http" - "time" ) const ( @@ -91,7 +92,7 @@ func (h *OIDC) HandleCallback(w http.ResponseWriter, r *http.Request) { id.SetAuthTime(time.Now()) id.SetAttribute(identity.AttrAccessToken, oauth2Token.AccessToken) - if err = SaveSessionIdentity(r, w, id); err != nil { + if err := SaveSessionIdentity(r, w, id); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } diff --git a/cmd/rdpgw/web/web.go b/cmd/rdpgw/web/web.go index 30f4b85..42bc036 100644 --- a/cmd/rdpgw/web/web.go +++ b/cmd/rdpgw/web/web.go @@ -1,13 +1,12 @@ package web import ( + "bytes" "context" "crypto/rand" "encoding/hex" "errors" "fmt" - "github.com/bolkedebruin/rdpgw/cmd/rdpgw/identity" - "github.com/bolkedebruin/rdpgw/cmd/rdpgw/rdp" "hash/maphash" "log" rnd "math/rand" @@ -15,6 +14,10 @@ import ( "net/url" "strings" "time" + + "github.com/andrewheberle/rdpsign" + "github.com/bolkedebruin/rdpgw/cmd/rdpgw/identity" + "github.com/bolkedebruin/rdpgw/cmd/rdpgw/rdp" ) type TokenGeneratorFunc func(context.Context, string, string) (string, error) @@ -32,6 +35,8 @@ type Config struct { GatewayAddress *url.URL RdpOpts RdpOpts TemplateFile string + RdpSigningCert string + RdpSigningKey string } type RdpOpts struct { @@ -51,6 +56,7 @@ type Handler struct { hostSelection string rdpOpts RdpOpts rdpDefaults string + rdpSigner *rdpsign.Signer } func (c *Config) NewHandler() *Handler { @@ -58,7 +64,7 @@ func (c *Config) NewHandler() *Handler { log.Fatal("Not enough hosts to connect to specified") } - return &Handler{ + handler := &Handler{ paaTokenGenerator: c.PAATokenGenerator, enableUserToken: c.EnableUserToken, userTokenGenerator: c.UserTokenGenerator, @@ -70,6 +76,18 @@ func (c *Config) NewHandler() *Handler { rdpOpts: c.RdpOpts, rdpDefaults: c.TemplateFile, } + + // set up RDP signer if config values are set + if c.RdpSigningCert != "" && c.RdpSigningKey != "" { + signer, err := rdpsign.New(c.RdpSigningCert, c.RdpSigningKey) + if err != nil { + log.Fatal("Could not set up RDP signer", err) + } + + handler.rdpSigner = signer + } + + return handler } func (h *Handler) selectRandomHost() string { @@ -160,7 +178,7 @@ func (h *Handler) HandleDownload(w http.ResponseWriter, r *http.Request) { render := user if opts.UsernameTemplate != "" { - render = fmt.Sprintf(h.rdpOpts.UsernameTemplate) + render = fmt.Sprint(h.rdpOpts.UsernameTemplate) render = strings.Replace(render, "{{ username }}", user, 1) if h.rdpOpts.UsernameTemplate == render { log.Printf("Invalid username template. %s == %s", h.rdpOpts.UsernameTemplate, user) @@ -224,5 +242,23 @@ func (h *Handler) HandleDownload(w http.ResponseWriter, r *http.Request) { d.Settings.GatewayCredentialMethod = 1 d.Settings.GatewayUsageMethod = 1 - http.ServeContent(w, r, fn, time.Now(), strings.NewReader(d.String())) + // no rdp siging so return as-is + if h.rdpSigner == nil { + http.ServeContent(w, r, fn, time.Now(), strings.NewReader(d.String())) + return + } + + // get rdp content + rdpContent := d.String() + + // sign rdp content + signedContent, err := h.rdpSigner.Sign(rdpContent) + if err != nil { + log.Printf("Could not sign RDP file due to %s", err) + http.Error(w, errors.New("could not sign RDP file").Error(), http.StatusInternalServerError) + return + } + + // return signd rdp file + http.ServeContent(w, r, fn, time.Now(), bytes.NewReader(signedContent)) } diff --git a/cmd/rdpgw/web/web_test.go b/cmd/rdpgw/web/web_test.go index 6c53ca0..a57aa96 100644 --- a/cmd/rdpgw/web/web_test.go +++ b/cmd/rdpgw/web/web_test.go @@ -2,15 +2,25 @@ package web import ( "context" - "github.com/bolkedebruin/rdpgw/cmd/rdpgw/identity" - "github.com/bolkedebruin/rdpgw/cmd/rdpgw/rdp" - "github.com/bolkedebruin/rdpgw/cmd/rdpgw/security" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" "net/http" "net/http/httptest" "net/url" "os" "strings" "testing" + "time" + + "github.com/andrewheberle/rdpsign" + "github.com/bolkedebruin/rdpgw/cmd/rdpgw/identity" + "github.com/bolkedebruin/rdpgw/cmd/rdpgw/rdp" + "github.com/bolkedebruin/rdpgw/cmd/rdpgw/security" + "github.com/spf13/afero" ) const ( @@ -172,6 +182,89 @@ func TestHandler_HandleDownload(t *testing.T) { } +func TestHandler_HandleSignedDownload(t *testing.T) { + req, err := http.NewRequest("GET", "/connect", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + id := identity.NewUser() + + id.SetUserName(testuser) + id.SetAuthenticated(true) + + req = identity.AddToRequestCtx(id, req) + ctx := req.Context() + + u, _ := url.Parse(gateway) + c := Config{ + HostSelection: "roundrobin", + Hosts: hosts, + PAATokenGenerator: paaTokenMock, + GatewayAddress: u, + RdpOpts: RdpOpts{SplitUserDomain: true}, + } + h := c.NewHandler() + + // set up rdp signer + fs := afero.NewMemMapFs() + if err := genKeypair(fs); err != nil { + t.Errorf("could not generate key pair for testing: %s", err) + } + signer, err := rdpsign.New("test.crt", "test.key", rdpsign.WithFs(fs)) + if err != nil { + t.Errorf("could not create *rdpsign.Signer for testing: %s", err) + } + h.rdpSigner = signer + + hh := http.HandlerFunc(h.HandleDownload) + hh.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusOK { + t.Errorf("handler returned wrong status code: got %v want %v", + status, http.StatusOK) + } + + if ctype := rr.Header().Get("Content-Type"); ctype != "application/x-rdp" { + t.Errorf("content type header does not match: got %v want %v", + ctype, "application/json") + } + + if cdisp := rr.Header().Get("Content-Disposition"); cdisp == "" { + t.Errorf("content disposition is nil") + } + + data := rdpToMap(strings.Split(rr.Body.String(), rdp.CRLF)) + if data["username"] != testuser { + t.Errorf("username key in rdp does not match: got %v want %v", data["username"], testuser) + } + + if data["gatewayhostname"] != u.Host { + t.Errorf("gatewayhostname key in rdp does not match: got %v want %v", data["gatewayhostname"], u.Host) + } + + if token, _ := paaTokenMock(ctx, testuser, data["full address"]); token != data["gatewayaccesstoken"] { + t.Errorf("gatewayaccesstoken key in rdp does not match username_full address: got %v want %v", + data["gatewayaccesstoken"], token) + } + + if !contains(data["full address"], hosts) { + t.Errorf("full address key in rdp is not in allowed hosts list: go %v want in %v", + data["full address"], hosts) + } + + signscopeWant := "GatewayHostname,Full Address,GatewayCredentialsSource,GatewayProfileUsageMethod,GatewayUsageMethod,Alternate Full Address" + if data["signscope"] != signscopeWant { + t.Errorf("signscope key in rdp does not match: got %v want %v", data["signscope"], signscopeWant) + } + + if _, found := data["signature"]; !found { + t.Errorf("no signature found in rdp") + } + +} + func TestHandler_HandleDownloadWithRdpTemplate(t *testing.T) { f, err := os.CreateTemp("", "rdp") if err != nil { @@ -233,3 +326,68 @@ func rdpToMap(rdp []string) map[string]string { return ret } + +func genKeypair(fs afero.Fs) error { + // generate private key + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return err + } + + // convert to DER + der, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + return err + } + + // encode DER private key as PEM + if err := func() error { + f, err := fs.Create("test.key") + if err != nil { + return err + } + defer f.Close() + + return pem.Encode(f, &pem.Block{ + Type: "PRIVATE KEY", + Bytes: der, + }) + }(); err != nil { + return err + } + + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + Organization: []string{"Example Organization"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Minute * 10), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) + if err != nil { + return err + } + + // encode cert as PEM + if err := func() error { + f, err := fs.Create("test.crt") + if err != nil { + return err + } + defer f.Close() + + return pem.Encode(f, &pem.Block{ + Type: "CERTIFICATE", + Bytes: certBytes, + }) + }(); err != nil { + return err + } + + return nil +} diff --git a/go.mod b/go.mod index ca53951..6fc4ca4 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,9 @@ module github.com/bolkedebruin/rdpgw -go 1.23.0 - -toolchain go1.24.1 +go 1.24.2 require ( + github.com/andrewheberle/rdpsign v1.1.0 github.com/bolkedebruin/gokrb5/v8 v8.5.0 github.com/coreos/go-oidc/v3 v3.9.0 github.com/fatih/structs v1.1.0 @@ -25,6 +24,7 @@ require ( github.com/msteinert/pam/v2 v2.0.0 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/prometheus/client_golang v1.19.0 + github.com/spf13/afero v1.14.0 github.com/stretchr/testify v1.10.0 github.com/thought-machine/go-flags v1.6.3 golang.org/x/crypto v0.36.0