Initial commit: IT Nexus Web-App
This commit is contained in:
182
nexus-scanner/internal/modules/adsync/adsync.go
Normal file
182
nexus-scanner/internal/modules/adsync/adsync.go
Normal file
@@ -0,0 +1,182 @@
|
||||
// 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()
|
||||
}
|
||||
Reference in New Issue
Block a user