Skip to content

Domains Developer Guide

Ed Mozley edited this page Oct 4, 2026 · 4 revisions

πŸ› οΈ Domains - Developer Guide

How the Domains module is put together: the files, the one service every write goes through, how lookups and checks work, how alerts fire exactly once, and the traps found while building it. For the plain-language version see Domains; for the API, REST API: Domains.


1. πŸ“ The files

Colour key: πŸ—„οΈ schema Β· βš™οΈ engine Β· ✏️ write Β· πŸ“– read Β· πŸ”— cross-module Β· πŸ”Œ REST Β· πŸ–₯️ UI Β· ⏱️ schedule

🎨 File What it does
πŸ—„οΈ database/freeitsm.sql, includes/db_verify_schema.php, includes/db_verify_indexes.php 12 tables: domains, domain_statuses, domain_registrar_accounts, domain_audit, domain_alerts_sent, domain_lookalikes, domain_certificates, and (3.0.0) the links domain_cmdb_objects, domain_status_services, ticket_domains, domain_knowledge_articles plus domain_status_incidents
πŸ—„οΈ api/system/db_verify.php the 29 foreign keys, the status seed, the cron token seed
βš™οΈ includes/domains/settings.php every setting: key, default, validator, owning tab - one table
βš™οΈ includes/domains/names.php domainNormalise() - every path in goes through it; purposes; sub-domain rule for certificate hosts
βš™οΈ includes/domains/lookup.php RDAP (IANA bootstrap cached 7 days), WHOIS fallback, lock/flag derivation, the shared HTTP client
βš™οΈ includes/domains/dns.php one DNS question, two resolvers (system / DNS-over-HTTPS), normalised answers
βš™οΈ includes/domains/tls.php read a certificate, then verify its chain - two handshakes
βš™οΈ includes/domains/checks.php the findings and the grade
βš™οΈ includes/domains/monitor.php change detection: baseline, diff, what counts as serious
βš™οΈ includes/domains/watch.php crt.sh Certificate Transparency and the look-alike generator/scanner
βš™οΈ includes/domains/alerts.php due alerts, digests, fire-once ledger, renewal task/ticket
βš™οΈ includes/domains/calendar.php two kinds into the Calendar - domain and certificate renewals - each with its own marker, setting and category (reuses the warranty helpers, Β§13)
βš™οΈ includes/domains/links.php every connection rule - CMDB, Service Status, tickets, knowledge, contract (Β§11)
βš™οΈ includes/domains/status_link.php Service Status: trouble, raise once, resolve on recovery (Β§12)
⏱️ includes/domains/scheduler.php, cron/domains.php the time-budgeted scheduled run, and the page-load fallback
✏️ includes/services/domains.php DomainsService - every write, for the screens, the API and the scheduler
πŸ“– includes/domains/read.php the register list and one domain for the screens; attention rules; lookups for forms
πŸ–₯️ api/domains/*.php + includes/domains/api_bootstrap.php thin UI endpoints - links.php serves the connections for both sides
πŸ–₯️ assets/js/domain-links.js, assets/css/domain-links.css the Domains panel other modules mount (Β§11)
πŸ–₯️ domains/ (register, domains/view.php, table/, dashboard/, accounts/, settings/, domains/help.php), assets/js/domains*.js, assets/css/domains.css, lang/en/domains.php the screens
πŸ”Œ api/v1/resources/domains.php, api/v1/lib/routes.php, api/v1/lib/permissions.php, api/v1/spec.json, api/v1/lib/openapi_schemas.php the REST resource
πŸ”— includes/tenancy.php analystCanAccessDomain()
πŸ”— includes/watchtower_*.php, watchtower/index.php, watchtower/settings/index.php the Watchtower card
πŸ”— workflow/includes/engine.php triggers + payload fields
πŸ”— includes/services/notifications.php, includes/notifications_router.php, includes/entity_links.php bell types, recipient (the owner), deep link
πŸ”— includes/record_preview.php, includes/documents.php, includes/recent_trail.php, api/system/global_search.php, assets/js/command-palette.js, includes/table_views.php previews, documents, recent trail, search, saved views
πŸ”— contracts/suppliers/view/index.php "Domains registered here" on a registrar's page
πŸ”— cmdb/object.php + object.js, assets/js/inbox.js + tickets/index.php, knowledge/index.php + assets/js/knowledge.js, contracts/view.php the other side of each connection (Β§11)
πŸ”— includes/capabilities.php, domains/settings/manifest.php Cap::DOMAINS_SERVICE_STATUS and its settings tab (Β§12)
πŸ§ͺ tests/domain-links.php, tests/domain-status-incidents.php, tests/domain-calendar-kinds.php the 3.0.0 rules, each with its controls
πŸ”— includes/feature_bingo/cards/domains.php, database/demo-data/domains.json Feature Bingo, demo data

What you do not touch to add a check: the screens. A finding is a key plus params; its words live in lang/en/domains.php under check.<key>, and every screen, the digest and the API render it from there.


2. One service, three callers

screens (api/domains/*) ─┐
REST API (api/v1)        β”œβ”€β–Ί DomainsService ─► domains + domain_audit + WorkflowEngine::dispatch()
scheduler (cron, tick)   β”€β”˜
  • loadForActor() starts every by-id method: a domain in a company the caller cannot reach reads as not found, never forbidden, so ids cannot be probed.
  • Every changed field gets a domain_audit row, whoever changed it; source says who: app, api, lookup, check, monitor, import. Ids are stored as names (auditDisplay()), so the history reads as English a year later.
  • The auth code is the exception: its change and every view are recorded, its value never is.
  • Deletes remove children by hand - an upgraded install whose foreign keys failed to add has no cascade to rely on. That includes the 3.0.0 link tables, each in its own try so a table not yet created never stops a delete. (Deleting the record on the other side - a CI, a ticket - relies on the cascade; a link left without its other end is never shown, because domainLinks() drops a row whose target fails the access check.)

3. Lookups

domainLookup() returns one normalised shape whatever the source, and never writes. DomainsService::applyLookup() decides what to keep:

  • the overwrite setting: always (the registry wins) or blanks (fill only what nobody typed);
  • silence is not an answer - a null from the registry (a .de expiry) never blanks a typed value;
  • registry statuses always follow the registry (people never type them);
  • the registrar is matched to a Contracts supplier by name when none is set, so the supplier page lists its domains.

πŸ”΄ Trap: .uk has no transfer lock

Nominet moves .uk domains by registrar tag, not auth code, so most show only active. Treating that as "unlocked" flagged every .uk domain as unprotected. But some registrars do set client transfer prohibited on .uk (measured: heartinternet.uk has it; freeitsm.co.uk at the same registrar does not). So: a transfer status present β†’ locked; none β†’ not applicable (transfer_lock = NULL, finding reg_transfer_uk, no points). See domainTransferLockNotApplicable().

Trap: DENIC

.de WHOIS needs -T dn,ace to say anything, and still publishes no expiry. Its status connect is not EPP vocabulary, so locks stay unknown rather than off.


4. DNS and certificates

πŸ”΄ Trap: Windows' resolver and big TXT answers

dns_get_record() on Windows returned false after 10 seconds for a domain with 22 TXT records (it cannot fall back to TCP), and has no DNS_CAA at all. DNS-over-HTTPS answered the same question in 0.3s. The auto resolver (the default) is DoH on Windows and the system resolver elsewhere, with DoH as the second opinion when the system resolver fails.

Trap: one TLS handshake per DoH call

A check asks about 25 DoH questions. With a fresh curl handle each time that was 7.5s a domain; one static handle reused (curl_reset() keeps the connection cache) brought it to 1-2s. domainHttpGet() owns that handle.

Certificates: two handshakes

Verification off to read the certificate whatever its state (an expired one is the one worth seeing), then on, against the same CA bundle as the rest of FreeITSM, to answer "would a browser trust this?".

Extra hosts can never be pointed elsewhere

domainParseSslHosts() keeps only the domain and its own sub-domains. Otherwise "extra hosts to check" would be a way to make the server connect to arbitrary or internal addresses.


5. The grade

domainRunChecks() returns findings ['key','area','level','weight','params']. Warnings and failures subtract their weight from 100; domainGrade() maps the score to A+…F, with expired, redemption or NXDOMAIN forced to F. The purpose changes the questions: domainPurposeIsNonMail() swaps the mail checks for the lock-down checks.

To add a check: add $add('key', 'area', 'level', weight, params) in includes/domains/checks.php, then check.key.title / .advice in the lang file. The key-audit script used during the build (every $add key must have a title) is the quickest way to prove nothing is missing.


6. Change detection

After each check, domainBaseline() records the registrar, locks, DNSSEC, live name servers, MX, SPF and DMARC. domainBaselineDiff() compares with the last run:

  • known β†’ different known is a change; known β†’ unknown is a gap in our knowledge, never an alert (a registry having a slow afternoon must not page anybody);
  • domainBaselineMerge() keeps the last known value through a gap, so its reappearance is not a change either;
  • A/AAAA records are deliberately not watched - CDNs change them constantly;
  • the record's own name-server list is not watched either: a person editing it is not a hijack, and a real hijack shows in live DNS.

Serious changes (registrar, name servers, MX moving; a lock or DNSSEC going off) are emailed at once via domainAlertOnChange().


7. Alerts fire once

domain_alerts_sent holds (domain, kind, fingerprint). The fingerprint carries the date the alert is about, so renewing re-arms every window with nothing to clear. Each audience has its own kind - evt_* (bell + workflows), mail_* (digest), act_* (task/ticket) - so switching email on later still sends the mail for a window whose event already fired.

Claim before sending (INSERT IGNORE), give it back on failure - the LMS reminders rule. A digest item is claimed once and released only if every digest carrying it failed.

πŸ”΄ Trap: time-based events and the bell

workflowEmitOnce() (the contract/warranty expiry path) returns early when no workflow is listening - and the bell is fed from dispatch(), so a bell notification for a time-based event would never arrive on an install with no workflows. Domains keeps its own ledger and calls WorkflowEngine::dispatch() directly.


8. The scheduled run

domainScheduledRun() works to a time budget, not a count: lookups (paced per registry host), then checks, then the weekly watches, then alerts, the calendar (both kinds) and domainStatusRun() - Service Status raising (mode auto) and resolving (Β§12). Oldest first, never-looked-up first; each domain finished or not started. cron/domains.php holds a MySQL GET_LOCK so two runs never overlap. The page-load fallback (api/domains/tick.php) runs a 25-second batch at most once an hour and never waits the page.


9. Companies

Domains and registrar accounts are scoped data (tenant_id NULL = the Default company). Lists use activeTenantReadFilter() (widens in the All-companies view); by-id reads use analystCanAccessDomain(); the service enforces companyScope; the REST API uses apiKeyTenantFilter() / apiKeyCanAccessTenantRow(). A registrar account from another company cannot be put on a domain - the link itself would leak. The same name may exist once per company.

The Watchtower card is the first card scoped by company and module access - the others leave that to the page. Its "services at risk" count follows the same company filter.

Connections keep to the company (Β§11): a CI or ticket must be in the domain's company, checked in domainLinkTargetOk() and narrowed in SQL by every picker. Status services are install-wide and knowledge articles have their own audience model, so those two are judged by their own permissions only.


10. Traps worth remembering

Trap What happened
ssl is reserved in MySQL the Watchtower query aliased a column AS ssl and failed inside a catch, so the card silently showed nothing. Renamed ssl_expiring
ltrim($s, 'dns_') strips characters, not a prefix - dns_ns became empty. Replaced with a map
const inside if (!defined(...)) a parse error; define() instead
openapi_fix.php rewrites the whole schemas file with var_export, deleting its comments. Schemas were added by text insertion instead; the file is CRLF, so anchors must be too
chart month labels built from midnight on the 1st, shifted into the previous month by the viewer's timezone handling. Mid-month UTC now
a mount() called twice the shared Domains panel was re-mounted on the same box (the CMDB page re-renders; Knowledge refreshes its reading view from the editor) and stacked a second set of listeners, so one click linked twice. Each box is wired once and calls the latest mount's functions (host._dl) - proved by counting POSTs per click
fmtDate on a calendar date shifts it a day west of UTC. Every domain date on another module's page goes through fmtNaiveDate
"Operational" is the default impact a blank impact would have raised an incident saying the service was fine. Blank now means the most severe level that counts as downtime
status_incident_updates cascade DB Verify never adds fk_siu_incident, so on an upgraded install deleting an incident leaves its updates behind. The status test deletes them by hand

11. Connections (3.0.0)

One file decides everything: includes/domains/links.php. Four join tables plus the domain's own contract_id:

Kind Table Other module Same company?
cmdb domain_cmdb_objects cmdb - analystCanAccessCmdbObject() yes
service domain_status_services service-status - install-wide no (services have no company)
ticket ticket_domains tickets - analystCanAccessTicket(), not deleted yes
article domain_knowledge_articles knowledge - knowledgeCanRead() no (articles have their own audience model)

πŸ”‘ The rule, both directions: a link is shown, made or removed only when the analyst can open Domains and the other module and the record itself. domainLinks() drops rows they cannot see rather than marking them hidden; domainLinkSearch() / domainLinkPickDomains() run the same per-record check as domainLinkAdd(), so a picker never offers something the add would refuse. Removing needs the same right as adding.

One endpoint, both sides: api/domains/links.php - ?domain_id= (the tab, plus the Service Status state), &search=KIND (link from the domain), ?for=KIND&id= (the other side's list), &pick=1 (link from the other side), ?contract_id=, and POST add / remove / set_contract (through DomainsService::updateDomain, so audited and contract-visibility checked) / raise_incident.

The other side is one shared panel, assets/js/domain-links.js + assets/css/domain-links.css: DomainLinks.mount(box, {kind, id, base, cardClass…, editable, hideEmpty, bare, onChange}). Mounted by the CMDB object page (in the page's own o2-card), a contract (bare, under the page's own heading) and Knowledge - list-only and hidden when empty while reading, linkable in the editor, the documents rule (reading is not editing). Tickets use pills in the Links strip instead (loadTicketDomains() in inbox.js), matching CMDB objects there. Every host page links the panel only when the analyst can open Domains and exports the domains translation namespace.

⚠️ The contract picker only offers domains with no contract. A domain has one contract_id; offering one under another contract would silently move it. Moving is a deliberate change, in its Edit.

12. Service Status (3.0.0)

includes/domains/status_link.php. Settings on their own tab (service-status, Cap::DOMAINS_SERVICE_STATUS, sensitive):

domain_status_mode (off Β· suggest Β· auto) Β· domain_status_on_expired Β· domain_status_on_cert Β· domain_status_cert_days Β· domain_status_impact Β· domain_status_public (0) Β· domain_status_auto_resolve (1)

  • Trouble = domainStatusProblems(): past expiry_date, or ssl_expiry_date past / within N days - only for a domain whose status wants alerts and which is not Do not renew (the Watchtower card's $live rule).
  • Never twice: domain_status_incidents (domain, trigger, fingerprint = the date) - the domain_alerts_sent rule. One open incident per domain; a person resolving it does not let the same dates raise again; a new date re-arms.
  • Created and resolved through ServiceStatusService::saveIncident, so the opening update, impact snapshot and workflow events are the same as one raised by hand. The scheduler's actor is actorId 0 ("Domains").
  • domainStatusRun() runs at the end of the scheduled run: raise (mode auto), then resolve (auto-resolve) when none of the reasons an incident was raised for still holds.

Tested by tests/domain-status-incidents.php (22): it makes its own domain and service, so it touches nothing real, and deletes them with every incident and update it raised.

13. Calendar kinds (3.0.0)

domainSyncExpiryCalendar() now draws two kinds, each with its own source, setting and category - the Warranty and lease rules:

source Setting Category
domain_expiry domain_expiry_surface Domain renewals
domain_cert domain_cert_surface (new; default dashboard, as the card always counted them) Certificate renewals

Each kind adopts its category before clearing its entries and remembers it by id (<source>_category_id), so a renamed category is never duplicated. A manual Check now that changes the certificate date re-syncs the calendar; the scheduled run syncs once at its end. Tested by tests/domain-calendar-kinds.php (14), including rename-then-sync and adopting a rename from before ids were kept.

The Watchtower card shows when either surface asks for the dashboard, each figure only under its own setting; certificates use domain_ssl_warn_days (was a fixed 21) and skip deliberately-lapsing domains.

14. Right-click (3.0.0)

In domains-register.js, the Tasks menu's shape: one #domCtx built once, submenus that flip at the edge, keyboard arrows. Every action goes through the endpoint the domain's page uses (bulk_update.php, process.php, delete.php); Edit opens view.php?id=N&edit=1, which opens the dialog after load. Copy uses window.copyToClipboard (works on plain HTTP).

15. Customer and technical contact (#153, #162)

Column Points at Since
owner_analyst_id analysts 2.9.0 β€” who is responsible; alerts go here
tech_contact_id contacts (a supplier contact) 2.9.0
tech_analyst_id analysts 3.1.0 (#162)
customer_user_id users, in the domain's company 3.0.0 (#153)
customer_supplier_id suppliers 3.1.0 (#162)
customer_contact_id contacts 3.1.0 (#162)

Add, never swap. #162 asked for the owner to be the customer and the technical contact to be "us". Re-pointing owner_analyst_id or tech_contact_id would have changed what every existing record, alert, report and API client means by them, so the new kinds are new columns beside the old ones.

One of each kind β€” DomainsService::normaliseParties(), run before the field loop in both createDomain() and updateDomain():

  • technical contact: tech_contact_id XOR tech_analyst_id. Setting one nulls the other; both non-empty in one request is a ServiceError.
  • customer: customer_user_id XOR (customer_supplier_id [+ customer_contact_id]). A contact sets the supplier to its own contacts.supplier_id; a contact plus a different supplier is refused; a new supplier drops a stored contact from another supplier; clearing the supplier clears the contact. Clearing a field otherwise clears only that field.

Because setting one kind clears the other, a caller never needs to know what was stored before β€” the REST API can PATCH one field.

Readiness. domainPartiesReady() (in includes/domains/customer.php) probes all three columns at once. fieldReady() skips them in both write paths, normaliseParties() returns early, domainListSelect() and apiDomainSelect() select NULL AS … in their place, and domainApiLookups() returns parties_ready so the edit dialog leaves out the Analysts group. Proved by saving through api/domains/save.php on a database without the columns.

Who may see contacts. Supplier contacts are Contracts' records. L.contacts has always been empty for an analyst without Contracts, and the customer search (api/domains/people.php) drops kind: contact rows for them too. Suppliers' names are not hidden β€” the registrar list already shows them to everyone in Domains.

The edit dialog (assets/js/domains-view.js): the technical contact is one <select> with values a:<id> / c:<id>; the customer is the existing search box, its hidden value now user:<id>, supplier:<id> or contact:<id>. πŸ”΄ Both are sent only when they changed from what the dialog opened with (techAtOpen, custAtOpen). Before 3.1.0 an analyst without Contracts opened the dialog with an empty contact list, so saving posted a blank tech_contact_id and silently cleared it (#2137). The current contact is now also always kept as an option, and an inactive analyst likewise, so the dialog shows the truth.

People pages. The domain page links the technical contact and a supplier customer to people/contact.php / people/supplier.php (only for analysts with People and Contracts β€” window.DOM_PEOPLE_SUPPLIERS). Those pages list domains through peopleSupplierDomains(), which reports each domain's role (registrar / customer / tech) and keeps to the analyst's accessible companies. See People β€” Developer Guide.

Tests. php tests/domain-parties.php β€” 15 checks of the rules above, in a rolled-back transaction.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally