183 lines
4.9 KiB
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()
|
|
}
|