// Go Ldap Api // Copyright (c) 2022 yo000 // package main import ( "os" "fmt" "flag" "time" "strings" "encoding/json" "github.com/spf13/viper" "github.com/gin-gonic/gin" "github.com/go-ldap/ldap/v3" log "github.com/sirupsen/logrus" ) var ( gVersion = "0.4" ) func marshalResultToText(res *ldap.SearchResult, delimiter string, showValueName, showDN bool) string { var txtRes string for _, e := range res.Entries { if showDN { if showValueName { txtRes = fmt.Sprintf("%s%s\n", txtRes, e.DN) } else { txtRes = fmt.Sprintf("%sdn%s%s\n", txtRes, delimiter, e.DN) } } for _, a := range e.Attributes { for _, v := range a.Values { if showValueName { txtRes = fmt.Sprintf("%s%s\n", txtRes, v) } else { txtRes = fmt.Sprintf("%s%s%s%s\n", txtRes, a.Name, delimiter, v) } } } // No DN = No linefeed between entries if showDN { txtRes = fmt.Sprintf("%s\n", txtRes) } } return txtRes } func sendResponse(c *gin.Context, res *ldap.SearchResult, format string) { // 404 Not found if len(res.Entries) == 0 { if strings.EqualFold(format, "json") { c.JSON(404, gin.H{"error": "No result"}) } else { c.String(404, "No result") } return } log.Debugf("Got %d results", len(res.Entries)) if strings.EqualFold(format, "json") { jsonRes, err := json.Marshal(res.Entries) if err != nil { log.Errorf("Error marshalling result to json: %v", err) } log.Debugf("%v\n", string(jsonRes)) c.String(200, string(jsonRes)) } else if strings.EqualFold(format, "text") { txtRes := marshalResultToText(res, "=", false, true) log.Debugf("%v\n", string(txtRes)) c.String(200, string(txtRes)) } else if strings.EqualFold(format, "ldif") { txtRes := marshalResultToText(res, ": ", false, true) log.Debugf("%v\n", string(txtRes)) c.String(200, string(txtRes)) } else if strings.EqualFold(format, "textvalue") { txtRes := marshalResultToText(res, "", true, true) log.Debugf("%v\n", string(txtRes)) c.String(200, string(txtRes)) } else if strings.EqualFold(format, "textvalue-nodn") { txtRes := marshalResultToText(res, "", true, false) log.Debugf("%v\n", string(txtRes)) c.String(200, string(txtRes)) } } func checkIfModifiedSince(c *gin.Context, myldap *MyLdap, baseDn, cn, class, attributes string) (bool, error) { // FIXME: We need to cache the last result, because if an item is deleted from LDAP we won't see it and // we will return 304. So deletions will never make their way to Rspamd // For now, lets always return "Modified" return true, nil if len(c.Request.Header["If-Modified-Since"]) > 0 { t := strings.Replace(c.Request.Header["If-Modified-Since"][0], "GMT", "+0000", 1) ifModifiedSince, _ := time.Parse(time.RFC1123Z, t) log.Debugf("ifModifiedSince: %s", ifModifiedSince) res, err := doLdapSearch(myldap, baseDn, cn, class, "modifyTimestamp") if err != nil { log.Errorf("Error searching modifyTimestamp for %s in %s : %v", cn, baseDn, err) return true, err } // modifyTimestamp format mtFmt := "20060102150405Z" // Compare each object timestamp hasNewer := false for _, e := range res.Entries { for _, a := range e.Attributes { if strings.EqualFold(a.Name, "modifyTimestamp") { mt, _ := time.Parse(mtFmt, a.Values[0]) log.Debugf("%s modifyTimestamp: %s", e.DN, mt) if mt.Unix() > ifModifiedSince.Unix() { log.Debugf("%s is newer than %s: %s", e.DN, ifModifiedSince, mt) hasNewer = true break } } } if hasNewer { break } } if false == hasNewer { return false, nil } } return true, nil } // Basic Authentication handler // TODO: Where to store accounts? func basicAuth(c *gin.Context) { user, password, hasAuth := c.Request.BasicAuth() if hasAuth && user == "admin" && password == "admin" { log.Infof("[%s]: User %s successfully authenticated", c.Request.RemoteAddr, user) } else { c.AbortWithStatus(401) c.Writer.Header().Set("WWW-Authenticate", "Basic realm=Restricted") return } } func initRouter(r *gin.Engine, myldap *MyLdap) { r.GET("/ping", func(c *gin.Context) { c.JSON(200, gin.H{ "message": "pong", }) }) // All following routes need authentication r.GET("/:ou/:cn/:class", basicAuth, func(c *gin.Context) { ou := c.Param("ou") cn := c.Param("cn") class := c.Param("class") // json format is the default format := c.DefaultQuery("format", "json") log.Printf("Format : %s", format) res, err := doLdapSearch(myldap, ou, cn, class, "ALL") // If OU does not exist, we'll get err='LDAP Result Code 32 "No Such Object"' if err != nil { log.Errorf("Error searching %s in %s : %v", cn, ou, err) c.AbortWithError(500, err) return } sendResponse(c, res, format) return }) r.HEAD("/:ou/:cn/:class", basicAuth, func(c *gin.Context) { ou := c.Param("ou") cn := c.Param("cn") class := c.Param("class") format := c.DefaultQuery("format", "json") log.Printf("Format : %s", format) modified, err := checkIfModifiedSince(c, myldap, ou, cn, class, "ALL") if err != nil { c.AbortWithError(500, err) return } if modified { res, err := doLdapSearch(myldap, ou, cn, class, "ALL") if err != nil { log.Errorf("Error searching %s in %s : %v", cn, ou, err) c.AbortWithError(500, err) return } sendResponse(c, res, format) } else { c.String(304, "") } return }) r.GET("/:ou/:cn/:class/:attribute", basicAuth, func(c *gin.Context) { ou := c.Param("ou") cn := c.Param("cn") attr := c.Param("attribute") class := c.Param("class") format := c.DefaultQuery("format", "json") log.Printf("Format : %s", format) res, err := doLdapSearch(myldap, ou, cn, class, attr) if err != nil { log.Errorf("Error searching %s in %s : %v", cn, ou, err) c.AbortWithError(500, err) return } sendResponse(c, res, format) return }) r.HEAD("/:ou/:cn/:class/:attribute", basicAuth, func(c *gin.Context) { ou := c.Param("ou") cn := c.Param("cn") attr := c.Param("attribute") class := c.Param("class") format := c.DefaultQuery("format", "json") log.Printf("Format : %s", format) modified, err := checkIfModifiedSince(c, myldap, ou, cn, class, attr) if err != nil { c.AbortWithError(500, err) return } if modified { res, err := doLdapSearch(myldap, ou, cn, class, attr) if err != nil { log.Errorf("Error searching %s in %s : %v", cn, ou, err) c.AbortWithError(500, err) return } sendResponse(c, res, format) } else { c.String(304, "") } return }) } func main() { var confFile string var listen string var ldapHost string var ldapUser string var ldapPass string var ldapBaseDN string var debug bool flag.StringVar(&confFile, "config", "", "Path to the config file (optional)") flag.StringVar(&listen, "listen-addr", "0.0.0.0:8080", "listen address for server") flag.StringVar(&ldapHost, "ldap-host", "", "ldap host to connect to") flag.StringVar(&ldapUser, "ldap-user", "", "ldap username") flag.StringVar(&ldapPass, "ldap-pass", "", "ldap password") flag.StringVar(&ldapBaseDN, "ldap-base-dn", "", "ldap base DN") flag.BoolVar(&debug, "debug", false, "Set log level to debug") flag.Parse() if len(confFile) > 0 { viper.SetConfigFile(confFile) if err := viper.ReadInConfig(); err != nil { log.Fatalf("Could not open config file: %v", err) os.Exit(1) } } if strings.EqualFold(listen, "0.0.0.0:8080") && len(confFile) > 0 { l := viper.GetString("LISTEN") if len(l) > 0 { listen = l } } if len(ldapHost) == 0 { l := viper.GetString("LDAP_HOST") if len(l) > 0 { ldapHost = l } else { log.Fatal("No ldap-host defined!") } } if len(ldapUser) == 0 { l := viper.GetString("LDAP_USER") if len(l) > 0 { ldapUser = l } else { log.Fatal("No ldap-user defined!") } } if len(ldapPass) == 0 { l := viper.GetString("LDAP_PASS") if len(l) > 0 { ldapPass = l } else { log.Fatal("No ldap-pass defined!") } } if len(ldapBaseDN) == 0 { l := viper.GetString("LDAP_BASE_DN") if len(l) > 0 { ldapBaseDN = l } else { log.Fatal("No ldap-base-dn defined!") } } log.Println("Starting Go Ldap API v.", gVersion) if debug { log.SetLevel(log.DebugLevel) } r := gin.Default() ldap := MyLdap{Host: ldapHost, User: ldapUser, Pass: ldapPass, BaseDN: ldapBaseDN} initRouter(r, &ldap) r.Run(listen) }