Files
IT-Nexus/nexus-scanner/internal/modules/adsync/adsync.go

183 lines
4.9 KiB
Go

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