groupware: add DNS auto-discovery (currently disabled, needs testing)

This commit is contained in:
Pascal Bleser
2025-09-04 11:32:10 +02:00
parent ea2c99478b
commit e63a7c4bc5
3 changed files with 239 additions and 31 deletions

View File

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

View File

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

View File

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