Skip to content

Split DNS Developer Guide

Ed Mozley edited this page Nov 7, 2026 · 1 revision

Split DNS in Domains - Developer Guide

Issue: #249, the last part of #243 (internal monitoring, slice 3) Β· Module: Domains Β· From: the release after 4.10.0 (changelog #2744)

This page explains split DNS from the beginning - what it is, why anyone wants it, what was decided and why - and then every rule in the code and where it lives. If you have never heard of split DNS, start at the top; if you only need the code, jump to section 4.


1. What split DNS is, in plain English

DNS is the internet's phone book. You ask it for a name - example.com - and it gives you a number - an IP address - that your computer then connects to.

Normally there is one phone book, and everybody gets the same answer.

Split DNS means there are two phone books. People outside your organisation ask the public phone book and get one answer. People inside your network ask your own, internal phone book and get a different answer for the same name.

A typical example:

Who asks for portal.example.com Which phone book Gets Lands on
A customer at home The public one 203.0.113.10 The public website, on the internet
A member of staff in the office Your internal one 10.0.5.20 An internal server, inside the network

Organisations do this on purpose: staff reach a faster internal copy, or an internal-only version of a system, without having to remember a different name.

Why it matters for monitoring: the two sides can break independently. The public website can be fine while the internal server staff are sent to has an expired certificate - or the other way round. If FreeITSM only watches one side, it misses half the picture.


2. What FreeITSM already did before this

Domains already understood the two kinds of name, through a setting called Scope (#203, 4.5.0):

Scope What FreeITSM does What it never does
Public Looks the name up the way the world does - public DNS, the domain registry, certificate logs - and gives it a security grade (A+ to F) -
Internal Asks this server's own DNS - the internal phone book - and gives it a health (Healthy, Problem...) Sends the name anywhere outside the network

The second column is the important one. An internal name is never sent to a public service, because that would reveal it - that is what Internal scope is for. The single function every outbound path asks is domainMayLeaveNetwork($domain) in includes/domains/scope.php, and it answers from the record's own Scope.

What was missing: a name could only be in the register once. So portal.example.com could be watched as Public or as Internal - never both. That is the gap #249 closes.


3. The decision: two linked records, not one record with two views

The two ways it could have been built

Option A - one record, two perspectives (the original design, written before any of this was built). One portal.example.com record holding both a public result and an internal result side by side. Every place that stores results - findings, history, certificates - would gain a "perspective" label, so each row says whether it came from outside or inside.

Option B - two records, linked (what was built). portal.example.com is in the register twice: one record with Scope = Public, one with Scope = Internal. The page shows each one the link to the other.

Why Option B

Think of the two as two folders for the same name.

  • Each record already keeps its own everything. Its findings, its history, its certificates, its grade or health, its settings - all stored against that record. Two records therefore give two completely separate perspectives for free. Nothing is shared, so nothing can overwrite anything.
  • "Permission is never inherited" is automatic. Each record's Scope decides on its own whether anything may leave the network. The Internal record never asks the outside world, whatever its Public twin does. There is no shared setting to get wrong.
  • No storage change, so nothing to migrate. Option A meant changing how results are stored for every domain, including the public ones everybody already has, and deciding how to label years of existing history. Option B adds no table and no column. Existing data is untouched.
  • Reports and the REST API already understand it. They already show and filter by Scope, so they tell the two apart without change.
  • It is the reversible choice. If one record with two views is ever genuinely better, merging each pair is a one-off job that loses nothing. Undoing Option A would have meant unpicking a storage change across every domain.

The trade-off, stated plainly: owner, notes, tags and links belong to each record, so the two views can be owned by different people. That is usually right - the web team looks after the public site, the infrastructure team the internal server - but it does mean the two are kept separately. "Add an internal view" copies the owner across to start with, so the new record is not flagged as unowned.

The decision was posted on the issue before building (#249), as FreeITSM does for any change to an agreed design.


4. The rule, and where it is enforced

The rule: at most one record per name, per company, per scope.

So a name can be in the register once as Public and once as Internal - and never twice in the same scope. A name therefore has at most one twin.

"Per company" matters on multi-company installs: portal.example.com in company A and portal.example.com in company B are not twins - they are two separate companies' domains, as they always were.

The rule is checked everywhere a record could become a duplicate:

Where What could go wrong What happens Code
Adding a domain (New, Paste list, Import CSV, REST) A second Public (or Internal) record of a name Refused: "... is already in the register as a Public domain (id 12)" DomainsService::createDomain() β†’ domainSameScopeId()
Renaming a domain Renaming onto a name that already has a record of the same scope Refused DomainsService::updateDomain()
Changing Scope (Edit, bulk edit, REST) Switching the Internal twin to Public while the Public one exists Refused: "... one record per name and scope. Edit that one instead, or delete it first." DomainsService::updateDomain()
Both at once Renaming and changing scope onto a taken pair Refused - the check uses the name and scope the record would have updateDomain(), $nextName / $nextScope
Add an internal view A second internal view Refused (it goes through createDomain()) DomainsService::addInternalView()

Paste list and Import CSV go through createDomain() too, so a list containing portal.example.com with Scope = Internal now adds the internal twin instead of skipping it as a duplicate - and a genuine duplicate is still skipped. Two changes in api/domains/bulk_add.php make that work:

  • "Listed twice" means the same name and the same scope. A CSV can now hold portal.example.com once as Public and once as Internal; before, the second row was skipped as a repeat.
  • A CSV's Scope is tidied like its Purpose - Internal becomes internal - so a spreadsheet does not have to match the internal spelling. Before, a capital letter was refused.

Before Database Verification has added the scope column, every record is public, and domainSameScopeId() falls back to checking the name alone - exactly the rule before #249.

The two helper functions (includes/domains/scope.php)

domainTwin($conn, $id, $name, $storeTenant)            // the same name's OTHER record, or null
domainSameScopeId($conn, $name, $storeTenant, $scope)  // the record with this name AND scope, or null

There is deliberately no new column linking the twins. They are linked by being the same name in the same company - so a twin can never point at the wrong record, and there is no link to keep in step when one is renamed or deleted.


5. Subdomains: each picks the parent of its own scope

Since 3.16.0 a subdomain (intra.example.com) links to its parent (example.com) when the parent is in the register. With split DNS the parent's name can be there twice, so which does a subdomain link to?

The one of its own scope. An internal subdomain links to the internal parent; a public subdomain to the public parent.

  • When a subdomain is added: domainParentCandidates(..., $preferScope) in includes/domains/subdomains.php lists possible parents closest name first, and - between two records of the same name - the same scope first. The closest name still wins over scope; scope only breaks the tie between twins. The Edit dialog's Subdomain of list uses the same order.
  • When the second twin arrives later: say intra.example.com (Internal) was added when only the Public example.com existed, so it was linked to that. Then somebody adds the internal view of example.com. The internal subdomain moves to the new internal parent, and the public subdomains stay put. Each move is written in the subdomain's history: parent_domain_id: example.com (public) β†’ example.com (internal). Code: the end of createDomain().

Why move it? So the rule is the same whichever order things were added in. Without the move, the result would depend on history - which is exactly the kind of thing nobody can work out later.


6. "Add an internal view"

On a Public domain's page, under the name, there is a link: Add an internal view. It asks first, in plain words, then:

  1. creates an Internal record with the same name (DomainsService::addInternalView()),
  2. in the same company as the public record - not the company the analyst happens to have selected, because a twin in another company is not a twin,
  3. with the public record's owner, so it is not flagged as unowned,
  4. runs its first check straight away - DNS through this server only, which never leaves the network - and
  5. opens the new record.

Endpoint: api/domains/add_internal_view.php (POST {id}), the same access as adding a domain.

Once both exist, each page says so under the name, with a link across:

  • on the Public one: "Split DNS - also watched from inside the network: the internal view"
  • on the Internal one: "Split DNS - also watched from outside: the public view"

The page gets the twin from api/domains/get.php (twin: {id, scope}), and draws the line in twinLine() in assets/js/domains-view.js.

TRAP: one way only - Public β†’ Internal. There is no "Add a public view" on an internal domain, on purpose. Creating a public record for an internal name would send that name to public DNS, the registries and the certificate logs - the very leak Internal scope exists to prevent. Somebody who really means it (the name is public too) adds it deliberately with New. addInternalView() refuses an internal record, and the test checks it.


7. What did not change

  • The checks themselves. A Public record is checked exactly as before; an Internal record exactly as before. Split DNS is two ordinary records, so there is nothing new to check.
  • The gate. domainMayLeaveNetwork() is untouched - it was already per record.
  • Storage. No table, no column, nothing for Database Verification to do.
  • Reports, the dashboard, Watchtower. They count records, and already split public from internal by Scope. A name with twins appears once in the public counts and once in the internal ones, which is correct: it is being watched twice.
  • The REST API. GET /domains?domain_name=portal.example.com returns both twins; add &scope=internal or &scope=public for one. POST /domains with the name and "scope": "internal" creates the internal twin (it used to be refused as a duplicate). See REST API: Domains.

8. Files

File Change
includes/domains/scope.php domainTwin(), domainSameScopeId()
includes/services/domains.php the per-scope duplicate rule in createDomain() and updateDomain(); same-scope parent; moving subdomains to a new twin; addInternalView()
includes/domains/subdomains.php domainParentCandidates() takes $preferScope; domainSubdomains() returns each subdomain's scope
api/domains/add_internal_view.php New - the Add an internal view action
api/domains/bulk_add.php Paste list / CSV: "listed twice" is per name and scope; a CSV's Scope is lower-cased
api/domains/get.php twin and scope_ready for the page
assets/js/domains-view.js (?v=15) the twin line and the Add link
lang/en/domains.php the page strings, and help paragraph internal.p9
includes/feature_bingo/cards/domains.php card One name, watched from both sides
tests/domains-split-dns.php New - below

9. Tests - tests/domains-split-dns.php

php tests/domains-split-dns.php

It runs inside a database transaction that is always rolled back - nothing it adds survives, and its last check proves it. It never runs a check, so no name is looked up anywhere. 29 checks:

  1. Once per scope - a Public and an Internal record of a name both exist; a second of either is refused, saying which scope.
  2. Twins and permission - each finds the other; the Public may leave the network and the Internal may not (nothing inherited); a name in once has no twin; the Feature Bingo card ticks while a twin exists.
  3. No third record - switching either twin's scope is refused; a record without a twin changes scope freely; renaming onto a taken name is refused, also when combined with a scope change; and the refused edits changed nothing.
  4. Subdomains - internal to internal parent, public to public parent; the Edit dialog lists the same scope first.
  5. Add an internal view - same name, Internal, same company, same owner; the internal subdomain moves to it, the public one stays; the move is in the history, from which record to which; a second is refused; and one way only.
  6. Per company - the same name in two companies is not a twin.

Control: run against the code from before #249, it fails at the very first step - the old rule refuses the internal twin. So a pass means the new rule is really there.

The existing Domains tests still pass: domain-subdomains-certs.php (49), domains-internal-scope.php (18), domains-expected-records.php (12).

Checked in the browser: a public domain's page shows Add an internal view, with no script errors.

Not covered by an automated test: the Paste list / CSV endpoint's own "listed twice" check and Scope tidying (they live in the web endpoint, which the test does not call - checked by reading); and the twin line on screen, because the install it was built on had no internal domain to show it with, and clicking Add there would have made a real record.


10. Traps in one place

  • One record per name, per company, per scope. Not per name. Any new way of creating or editing a domain must go through createDomain() / updateDomain(), or it can make a third record.
  • Twins are linked by name, not by a column. Never add a "twin_id": it would have to be kept in step on every rename and delete, and could point at the wrong record.
  • Public β†’ Internal only, in one click. Never offer Internal β†’ Public: it would leak an internal name.
  • The twin belongs to the public record's company, not the analyst's selected one.
  • Subdomains go to the parent of their own scope - also when the second twin arrives after them.
  • www. is stripped when a name is added (the "paste a web address" tidy-up), so www.example.com is example.com. The test uses web. for that reason.

Related

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Responses to feedback

Blue sky thinking

Bugs resolved

Pull requests

Links

Clone this wiki locally