// Package adsync queries Active Directory for computer objects and stores // them in the local database. It enriches existing hosts with their AD // computer name and department, and records computers that are in AD but // not yet seen on the network (useful for finding offline/missing devices). package adsync import ( "context" "fmt" "log/slog" "strconv" "strings" "time" "github.com/go-ldap/ldap/v3" "github.com/cereda-systems/nexus-scanner/internal/config" "github.com/cereda-systems/nexus-scanner/internal/db" ) // Module queries Active Directory for all computer objects. type Module struct { site string cfg config.ADConfig store *db.Store log *slog.Logger } // New returns a new AD sync module. func New(site string, cfg config.ADConfig, store *db.Store) *Module { return &Module{ site: site, cfg: cfg, store: store, log: slog.With("module", "ad_sync"), } } func (m *Module) Name() string { return "ad_sync" } func (m *Module) Interval() time.Duration { return m.cfg.Interval } // Run executes one AD sync cycle. func (m *Module) Run(ctx context.Context) error { scanID, err := m.store.BeginScan(m.Name()) if err != nil { return fmt.Errorf("begin scan: %w", err) } scanErr := m.sync(ctx) if endErr := m.store.EndScan(scanID, scanErr); endErr != nil { m.log.Error("record scan end", "err", endErr) } return scanErr } func (m *Module) sync(ctx context.Context) error { addr := fmt.Sprintf("%s:%d", m.cfg.Server, m.cfg.Port) var conn *ldap.Conn var err error if m.cfg.TLS { conn, err = ldap.DialURL("ldaps://" + addr) } else { conn, err = ldap.DialURL("ldap://" + addr) } if err != nil { return fmt.Errorf("ldap dial %s: %w", addr, err) } defer conn.Close() if err := conn.Bind(m.cfg.BindDN, m.cfg.BindPassword); err != nil { return fmt.Errorf("ldap bind as %s: %w", m.cfg.BindDN, err) } computers, err := m.searchComputers(conn) if err != nil { return fmt.Errorf("ldap search: %w", err) } m.log.Info("computers found in AD", "count", len(computers)) saved := 0 for _, c := range computers { if err := m.store.UpsertADComputer(c); err != nil { m.log.Error("upsert ad computer", "cn", c.CN, "err", err) continue } saved++ m.log.Debug("ad computer synced", "cn", c.CN, "os", c.OS, "dept", c.Department) } m.log.Info("ad sync complete", "synced", saved, "total", len(computers)) return nil } func (m *Module) searchComputers(conn *ldap.Conn) ([]db.ADComputer, error) { req := ldap.NewSearchRequest( m.cfg.SearchBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, "(&(objectClass=computer)(objectCategory=Computer))", []string{ "cn", "distinguishedName", "operatingSystem", "operatingSystemVersion", "lastLogonTimestamp", "whenCreated", "description", "managedBy", }, nil, ) result, err := conn.SearchWithPaging(req, 500) if err != nil { return nil, fmt.Errorf("ldap search computers: %w", err) } var computers []db.ADComputer for _, entry := range result.Entries { c := db.ADComputer{ CN: entry.GetAttributeValue("cn"), DN: entry.GetAttributeValue("distinguishedName"), OS: entry.GetAttributeValue("operatingSystem"), OSVersion: entry.GetAttributeValue("operatingSystemVersion"), Owner: entry.GetAttributeValue("description"), Department: ouFromDN(entry.GetAttributeValue("distinguishedName")), } if raw := entry.GetAttributeValue("lastLogonTimestamp"); raw != "" { c.LastLogon = windowsTimeToGoTime(raw) } if raw := entry.GetAttributeValue("whenCreated"); raw != "" { c.ADCreated = parseLDAPGeneralizedTime(raw) } computers = append(computers, c) } return computers, nil } // ouFromDN extracts the first OU component from a distinguished name. // "CN=IT-NB-02,OU=Notebooks,OU=IT,DC=winkel,DC=local" → "Notebooks" func ouFromDN(dn string) string { for _, part := range strings.Split(dn, ",") { part = strings.TrimSpace(part) if strings.HasPrefix(strings.ToUpper(part), "OU=") { return part[3:] } } return "" } // windowsTimeToGoTime converts a Windows LDAP timestamp string (100-ns intervals // since 1601-01-01) to a Go time.Time. func windowsTimeToGoTime(raw string) time.Time { n, err := strconv.ParseInt(raw, 10, 64) if err != nil || n == 0 { return time.Time{} } // Offset between Windows epoch (1601-01-01) and Unix epoch (1970-01-01) // in 100-nanosecond intervals. const windowsToUnix int64 = 116444736000000000 unixNano := (n - windowsToUnix) * 100 return time.Unix(0, unixNano).UTC() } // parseLDAPGeneralizedTime parses LDAP generalizedTime format: "20240101120000.0Z" func parseLDAPGeneralizedTime(raw string) time.Time { // Trim trailing sub-second and timezone info for simple parsing. raw = strings.TrimSuffix(raw, "Z") if idx := strings.Index(raw, "."); idx != -1 { raw = raw[:idx] } t, err := time.Parse("20060102150405", raw) if err != nil { return time.Time{} } return t.UTC() }