From e63a7c4bc55db03c0d9bdcc86411720ab02f07bd Mon Sep 17 00:00:00 2001 From: Pascal Bleser Date: Thu, 4 Sep 2025 11:32:10 +0200 Subject: [PATCH] groupware: add DNS auto-discovery (currently disabled, needs testing) --- .../groupware/pkg/groupware/groupware_dns.go | 162 ++++++++++++++++++ .../pkg/groupware/groupware_error.go | 56 +++--- .../pkg/groupware/groupware_framework.go | 52 ++++-- 3 files changed, 239 insertions(+), 31 deletions(-) create mode 100644 services/groupware/pkg/groupware/groupware_dns.go diff --git a/services/groupware/pkg/groupware/groupware_dns.go b/services/groupware/pkg/groupware/groupware_dns.go new file mode 100644 index 0000000000..6ef89b677f --- /dev/null +++ b/services/groupware/pkg/groupware/groupware_dns.go @@ -0,0 +1,162 @@ +package groupware + +import ( + "errors" + "net" + "net/url" + "slices" + "strconv" + "strings" + "time" + + "github.com/miekg/dns" +) + +var ( + errDnsNoServerToAnswer = errors.New("no name server to resolve") // TODO better error message +) + +type DnsSessionUrlResolver struct { + defaultSessionUrl *url.URL + defaultDomain string + domainGreenList []string + domainRedList []string + config *dns.ClientConfig + client *dns.Client +} + +func NewDnsSessionUrlResolver(defaultSessionUrl *url.URL, defaultDomain string, + config *dns.ClientConfig, domainGreenList []string, domainRedList []string, + dialTimeout time.Duration, readTimeout time.Duration, +) (DnsSessionUrlResolver, error) { + // TODO the whole udp or tcp dialier configuration, see https://github.com/miekg/exdns/blob/master/q/q.go + + c := &dns.Client{ + DialTimeout: dialTimeout, + ReadTimeout: readTimeout, + } + + return DnsSessionUrlResolver{ + defaultSessionUrl: defaultSessionUrl, + defaultDomain: defaultDomain, + config: config, + client: c, + }, nil +} + +func (d DnsSessionUrlResolver) isGreenListed(domain string) bool { + if d.domainGreenList == nil { + return true + } + // normalize the domain name by stripping a potential "." at the end + if strings.HasSuffix(domain, ".") { + domain = domain[0 : len(domain)-2] + } + return slices.Contains(d.domainGreenList, domain) +} + +func (d DnsSessionUrlResolver) isRedListed(domain string) bool { + if d.domainRedList == nil { + return true + } + // normalize the domain name by stripping a potential "." at the end + if strings.HasSuffix(domain, ".") { + domain = domain[0 : len(domain)-2] + } + return !slices.Contains(d.domainRedList, domain) +} + +func (d DnsSessionUrlResolver) Resolve(username string) (*url.URL, *GroupwareError) { + // heuristic to detect whether the username is an email address + parts := strings.Split(username, "@") + domain := d.defaultDomain + if len(parts) <= 1 { + // it's not, but do we have a defaultDomain configured that we should use + // nevertheless then? + if d.defaultDomain == "" { + // we don't, then let's fall back to the static session URL instead + return d.defaultSessionUrl, nil + } + } else { + domain = parts[len(parts)-1] + if !d.isGreenListed(domain) { + return nil, &ErrorUsernameEmailDomainIsNotGreenlisted + } + if d.isRedListed(domain) { + return nil, &ErrorUsernameEmailDomainIsRedlisted + } + } + + // https://jmap.io/spec-core.html#service-autodiscovery + // + // A JMAP-supporting host for the domain example.com SHOULD publish a + // SRV record _jmap._tcp.example.com + // that gives a hostname and port (usually port 443). + // + // The JMAP Session resource is then https://${hostname}[:${port}]/.well-known/jmap + // (following any redirects). + + // we need a fully qualified domain name: must end with a dot + name := dns.Fqdn("_jmap._tcp." + domain) + + msg := &dns.Msg{ + MsgHdr: dns.MsgHdr{RecursionDesired: true}, + Question: make([]dns.Question, 1), + } + msg.SetQuestion(name, dns.TypeSRV) + + r, err := d.dnsQuery(d.client, msg) + if err != nil { + // TODO error + } + if r == nil || r.Rcode == dns.RcodeNameError { + // TODO domain not found + } + + for _, ans := range r.Answer { + switch t := ans.(type) { + case *dns.SRV: + scheme := "https" + host := t.Target // TODO need to check whether the hostname is indeed in t.Target? + port := t.Port + if (scheme == "https" && port != 443) || (scheme == "http" && port != 80) { + host = net.JoinHostPort(host, strconv.Itoa(int(port))) + } + + u := &url.URL{ + Scheme: scheme, + Host: host, + Path: "/.well-known/jmap", + } + + return u, nil + } + } + + return d.defaultSessionUrl, nil +} + +func (d DnsSessionUrlResolver) dnsQuery(c *dns.Client, msg *dns.Msg) (*dns.Msg, error) { + for _, server := range d.config.Servers { + address := "" + // if the server is IPv6, it is already expected to be wrapped in [brackets] when + // the configuration comes from /etc/resolv.conf and has been parsed using + // dns.ClientConfigFromFile, but let's check to make sure + if strings.HasPrefix(server, "[") && strings.HasSuffix(server, "]") { + address = server + ":" + d.config.Port + } else { + // this function will take care of properly wrapping in [brackets] if it's + // an IPv6 address string: + address = net.JoinHostPort(server, d.config.Port) + } + + r, _, err := c.Exchange(msg, address) + if err != nil { + return nil, err + } + if r == nil || r.Rcode == dns.RcodeNameError || r.Rcode == dns.RcodeSuccess { + return r, err + } + } + return nil, errDnsNoServerToAnswer +} diff --git a/services/groupware/pkg/groupware/groupware_error.go b/services/groupware/pkg/groupware/groupware_error.go index 4fba0176dd..ae95a0a3f4 100644 --- a/services/groupware/pkg/groupware/groupware_error.go +++ b/services/groupware/pkg/groupware/groupware_error.go @@ -144,27 +144,29 @@ func groupwareErrorFromJmap(j jmap.Error) *GroupwareError { } const ( - ErrorCodeGeneric = "ERRGEN" - ErrorCodeInvalidAuthentication = "AUTINV" - ErrorCodeMissingAuthentication = "AUTMIS" - ErrorCodeForbiddenGeneric = "AUTFOR" - ErrorCodeInvalidBackendRequest = "INVREQ" - ErrorCodeServerResponse = "SRVRSP" - ErrorCodeStreamingResponse = "SRVRST" - ErrorCodeServerReadingResponse = "SRVRRE" - ErrorCodeServerDecodingResponseBody = "SRVDRB" - ErrorCodeEncodingRequestBody = "ENCREQ" - ErrorCodeCreatingRequest = "CREREQ" - ErrorCodeSendingRequest = "SNDREQ" - ErrorCodeInvalidSessionResponse = "INVSES" - ErrorCodeInvalidRequestPayload = "INVRQP" - ErrorCodeInvalidResponsePayload = "INVRSP" - ErrorCodeInvalidRequestParameter = "INVPAR" - ErrorCodeInvalidRequestBody = "INVBDY" - ErrorCodeNonExistingAccount = "INVACC" - ErrorCodeIndeterminateAccount = "INDACC" - ErrorCodeApiInconsistency = "APIINC" - ErrorCodeInvalidUserRequest = "INVURQ" + ErrorCodeGeneric = "ERRGEN" + ErrorCodeInvalidAuthentication = "AUTINV" + ErrorCodeMissingAuthentication = "AUTMIS" + ErrorCodeForbiddenGeneric = "AUTFOR" + ErrorCodeInvalidBackendRequest = "INVREQ" + ErrorCodeServerResponse = "SRVRSP" + ErrorCodeStreamingResponse = "SRVRST" + ErrorCodeServerReadingResponse = "SRVRRE" + ErrorCodeServerDecodingResponseBody = "SRVDRB" + ErrorCodeEncodingRequestBody = "ENCREQ" + ErrorCodeCreatingRequest = "CREREQ" + ErrorCodeSendingRequest = "SNDREQ" + ErrorCodeInvalidSessionResponse = "INVSES" + ErrorCodeInvalidRequestPayload = "INVRQP" + ErrorCodeInvalidResponsePayload = "INVRSP" + ErrorCodeInvalidRequestParameter = "INVPAR" + ErrorCodeInvalidRequestBody = "INVBDY" + ErrorCodeNonExistingAccount = "INVACC" + ErrorCodeIndeterminateAccount = "INDACC" + ErrorCodeApiInconsistency = "APIINC" + ErrorCodeInvalidUserRequest = "INVURQ" + ErrorCodeUsernameEmailDomainNotGreenListed = "UEDGRE" + ErrorCodeUsernameEmailDomainRedListed = "UEDRED" ) var ( @@ -294,6 +296,18 @@ var ( Title: "API Inconsistency", Detail: "Internal APIs returned unexpected data.", } + ErrorUsernameEmailDomainIsNotGreenlisted = GroupwareError{ + Status: http.StatusUnauthorized, + Code: ErrorCodeUsernameEmailDomainNotGreenListed, + Title: "Domain is not greenlisted", + Detail: "The username email address domain is not greenlisted.", + } + ErrorUsernameEmailDomainIsRedlisted = GroupwareError{ + Status: http.StatusUnauthorized, + Code: ErrorCodeUsernameEmailDomainRedListed, + Title: "Domain is redlisted", + Detail: "The username email address domain is redlisted.", + } ) type ErrorOpt interface { diff --git a/services/groupware/pkg/groupware/groupware_framework.go b/services/groupware/pkg/groupware/groupware_framework.go index 0b0713c07d..d4d00fd954 100644 --- a/services/groupware/pkg/groupware/groupware_framework.go +++ b/services/groupware/pkg/groupware/groupware_framework.go @@ -12,6 +12,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/render" + "github.com/miekg/dns" "github.com/r3labs/sse/v2" "github.com/rs/zerolog" @@ -182,6 +183,8 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome keepStreamsAliveInterval := time.Duration(30) * time.Second // TODO configuration, make it 0 to disable keepalive sseEventTtl := time.Duration(5) * time.Minute // TODO configuration setting + useDnsForSessionResolution := false // TODO configuration setting, although still experimental, needs proper unit tests first + insecureTls := true // TODO make configurable m := metrics.New(prometheusRegistry, logger) @@ -211,14 +214,43 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome var sessionCache *ttlcache.Cache[sessionKey, cachedSession] { + sessionUrlResolver := func(_ string) (*url.URL, *GroupwareError) { + return sessionUrl, nil + } + if useDnsForSessionResolution { + defaultSessionDomain := "example.com" // TODO default domain from configuration + // TODO resolv.conf or other configuration + conf, err := dns.ClientConfigFromFile("/etc/resolv.conf") + if err != nil { + return nil, GroupwareInitializationError{Message: "failed to parse DNS client configuration from /etc/resolv.conf", Err: err} + } + + var domainGreenList []string = nil // TODO domain greenlist from configuration + var domainRedList []string = nil // TODO domain redlist from configuration + + dialTimeout := time.Duration(2) * time.Second // TODO configuration + readTimeout := time.Duration(2) * time.Second // TODO configuration + + dnsSessionUrlResolver, err := NewDnsSessionUrlResolver( + sessionUrl, + defaultSessionDomain, + conf, + domainGreenList, + domainRedList, + dialTimeout, + readTimeout, + ) + if err != nil { + return nil, GroupwareInitializationError{Message: "failed to instantiate the DNS session URL resolver", Err: err} + } + sessionUrlResolver = dnsSessionUrlResolver.Resolve + } + sessionLoader := &sessionCacheLoader{ - logger: logger, - jmapClient: &jmapClient, - errorTtl: sessionFailureCacheTtl, - sessionUrlProvider: func(username string) (*url.URL, *GroupwareError) { - // here is where we would implement server sharding - return sessionUrl, nil - }, + logger: logger, + jmapClient: &jmapClient, + errorTtl: sessionFailureCacheTtl, + sessionUrlProvider: sessionUrlResolver, } sessionCache = ttlcache.New( @@ -249,11 +281,11 @@ func NewGroupware(config *config.Config, logger *log.Logger, mux *chi.Mux, prome reason = fmt.Sprintf("unknown (%v)", r) } spentInCache := time.Since(item.Value().Since()) - typ := "successful" + tipe := "successful" if !item.Value().Success() { - typ = "failed" + tipe = "failed" } - logger.Trace().Msgf("%s session cache eviction of user '%v' after %v: %v", typ, item.Key(), spentInCache, reason) + logger.Trace().Msgf("%s session cache eviction of user '%v' after %v: %v", tipe, item.Key(), spentInCache, reason) } })