Repository navigation
Active Directory Import
A WindowsTarget list maintained by hand drifts from the domain, and the hosts
that never get added are the ones nobody knows are missing. DAD can query Active
Directory for computer objects and import the ones that are not already targets.
This is optional and Admin-only. Nothing changes if you do not configure a directory.
- Configure a directory under ActiveDirectoryConnection
(
/active_directory_connections): host, port, bind DN and password, search base, and the defaults to apply to whatever it finds. - Start an import from
/ad_import_batches/new: choose the directory, and choose workstations / servers / both. - DAD queries the directory and shows the machines it found that are not already targets. Nothing has been created yet.
- Uncheck what you do not want, confirm, and only then are the
WindowsTargetrows created — already configured with a priority band, channels, and a WinRM credential, so they poll on the next cycle.
bundle exec rails runner 'puts ActiveDirectoryConnection.enabled.pluck(:name, :host, :port)'This is the design decision that is not obvious from the requirement, and it is a security boundary rather than a UX nicety.
A WindowsTarget row makes this server connect out to address carrying a
domain credential. If the apply step took host names from the submitted form, a
crafted POST would create a target for any host an attacker named and hand it the
WinRM credential — SSRF with a domain login attached, arriving through a form
whose only visible affordance is unchecking boxes.
So the candidate set is written server-side at preview time, into
ad_import_batches.candidates, and apply intersects the submitted selection
with it. It may only ever remove from that set.
| posted address | outcome |
|---|---|
| offered by the preview, still selected | imported |
| offered by the preview, deselected | recorded as deselected, nothing created |
| never offered | recorded as rejected, nothing created
|
| already a target at apply time | recorded as skipped, nothing created |
A name the directory never returned cannot be imported, whatever is posted. The batch row doubles as an audit trail: who ran the query, what filter, what came back, what was actually applied, and when.
A batch applies once. Replaying the POST does not re-import against a stale candidate list.
The candidate list is stored as JSON, not YAML — YAML.load on a blob that
round-trips through storage is a deserialization footgun, and these are flat
scalars that need no richer type. Corrupt JSON reads as an empty list rather than
raising.
Paging. Active Directory caps a single search at MaxPageSize, 1000 entries
by default. A domain with more machines than that returns a truncated list,
and the truncation is silent — the operator sees a plausible list with no hint
that half the fleet is missing. net-ldap negotiates RFC 2696 paged results on
its own, so the fix is to not defeat it: no :size limit is ever passed, and
the attribute list is narrow and named rather than *.
Unknown operating system. A machine joined to AD may be Linux, an appliance,
or a Windows install whose operatingSystem attribute is simply unset. It matches
neither the workstation nor the server filter. Those hosts are listed but left
unchecked, and reported as a count, because a filter that quietly omits machines
is exactly how an operator ends up believing the fleet is fully covered when it is
not. Classification reads AD's free-text operatingSystem:
operatingSystem |
class |
|---|---|
Microsoft Windows Server 2019 Standard |
server |
Microsoft Windows 10 Pro |
workstation |
Windows 10 Pro for Workstations |
workstation |
Ubuntu 22.04 LTS, '', unset |
unknown |
"Server" is checked first and wins, so a SKU naming both is a server.
Domain controllers are flagged. A DC matches "servers", and bulk-enabling
WinRM polling against one is a decision worth making deliberately rather than
reaching by accident. primaryGroupID 516 (Domain Controllers) and 517 (Read-only
Domain Controllers) are surfaced in the preview so the operator can uncheck them.
AD returns dNSHostName fully qualified — web01.corp.example.com. An operator
adding a host by hand types web01. Matching those as different strings produces
two targets for one machine, which double-polls it and interleaves two
independent offset states.
So a host counts as already present if it matches the FQDN or its first DNS
label, case-insensitively (LOWER() on both sides, because Postgres = is
case-sensitive and AD returns hostnames in whatever case the machine registered
them). The check runs at preview and again at apply, since a target may have
been created by hand in between.
| field | what it is for |
|---|---|
password |
binds to the directory, to read computer objects |
poll_username / poll_password
|
the WinRM login applied to every host this connection imports |
They are not interchangeable. The second pair exists because an imported host with no credential is a target that can never poll — a feature that looks finished and is not. A connection missing it is allowed (importing the inventory first, then setting the credential, is legitimate), but the import form says so out loud rather than letting the result look like a successful import of hosts that silently do nothing.
Both are encrypted at rest by Rails' encrypts, exactly as WindowsTarget#password
is; the columns hold ciphertext. Consequences:
- Production needs
DAD_AR_ENCRYPTION_KEYandDAD_AR_ENCRYPTION_SALT; DAD refuses to boot in production without them. See Installation. - Rotating either value makes stored credentials unreadable.
- Neither credential is ever rendered back to a browser, on the HTML screens or in
the
.jsonforms. The edit form shows blank password fields, and submitting them blank leaves the stored credential alone. - The polling credential is read from the connection at apply time. It never travels through the browser on the way back from the preview page, so it cannot be substituted by a crafted POST either.
A bind credential is required. Anonymous bind is refused rather than allowed: it typically returns nothing useful, so an operator who left the field blank would see "no new hosts" and reasonably conclude the domain was already fully imported. A loud validation beats a plausible lie.
| port | behaviour |
|---|---|
| 636 | LDAPS, TLS before the bind |
| 389 | StartTLS is negotiated before the bind; the UI says so explicitly |
The model does not silently upgrade — it reports what will actually happen, and
the screens state whether a bind is protected. Certificates are verified against
the system trust store by default. WINDOWS_SSL_VERIFY=none is the existing
Windows escape hatch for an internal CA, reused here rather than inventing a
second knob for the same decision.
An unreachable directory is an ordinary condition, not a 500. The failure is
recorded in last_status and shown in the UI, because otherwise "query failed"
and "no new hosts" look identical, and the second one is a lie. The same reasoning
as reporting an access-denied event channel as "no events" — see
Windows Events.
A failed query records no batch, so there is nothing to accidentally apply.
One bad host must not cost the other 399 — the same principle as one unreadable
event-log channel not costing a host its other channels. apply returns
added / skipped / deselected / rejected rather than raising, and a host that
fails validation is reported with the reason while the rest of the import
proceeds.
The channels an import applies end up in a wevtutil command line executed on a
remote host that DAD authenticates to with a domain credential. An unvalidated
channel name is remote command execution on that machine. Names outside
[A-Za-z0-9._-/] are rejected at save time rather than escaped, for the same
reason as on WindowsTarget — the command is built for cmd.exe, and shell
escaping there is not something to get subtly wrong.
The same reasoning applies to the host address, which builds the WinRM endpoint URL.
The host filter is operator-supplied text that goes into an LDAP search filter.
Unescaped, a value containing *, ( or ) changes the structure of the filter:
*)(objectClass=user closes the real filter and opens a new one, turning a
computer lookup into something else entirely. Values are escaped per RFC 4515.
NUL is handled separately and must become the hex pair \00, not a backslash
followed by a NUL byte — the byte is still a protocol delimiter either way.
The bind DN and search base are not filter text: they reach net-ldap as
discrete bind-username and search-base arguments. Their format check is a
well-formedness guard that turns "you pasted the wrong thing" into an error at
save time rather than a confusing bind failure at query time — it is deliberately
permissive inside, because RFC 4514 allows parentheses in a DN value and real DNs
carry spaces, apostrophes and non-ASCII names.
active_directory_connections and ad_import_batches are Admin-only, for
reading as well as writing. That is deliberately stricter than windows_targets,
where reading the list is a Viewer action: the sensitive act here is not saving a
row, it is the enumeration. A query returns the entire machine inventory of a
domain — hostname, OS version, creation date — which is reconnaissance-grade
information about the internal network regardless of what gets imported afterwards.
See Access Control.
- The LDAP call is not exercised in CI. There is no domain controller in the test environment, so the search sits behind an injectable client — the same seam shape as the WinRM transport. Filter construction, escaping, classification, and the address matching are tested; the bind and paged search against a real AD are not.
-
operatingSystemis free text. Classification is a heuristic over AD's own strings, not a lookup against an attribute AD does not have. A machine whose OS string DAD does not recognise is reported as unknown rather than guessed at. -
Import is one-directional. DAD does not write to the directory, and removing
a
WindowsTargetdoes not touch the computer object. - A connection's polling credential is shared across every host it imports, which is the normal setup for a fleet. Per-host overrides remain available on the individual target.
Related: Windows Events, Access Control, Interface.