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