Split DNS Developer Guide - edmozley/freeitsm GitHub Wiki
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.comonce as Public and once as Internal; before, the second row was skipped as a repeat. - A CSV's Scope is tidied like its Purpose -
Internalbecomesinternal- 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)inincludes/domains/subdomains.phplists 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 Publicexample.comexisted, so it was linked to that. Then somebody adds the internal view ofexample.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 ofcreateDomain().
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:
- creates an Internal record with the same name (
DomainsService::addInternalView()), - 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,
- with the public record's owner, so it is not flagged as unowned,
- runs its first check straight away - DNS through this server only, which never leaves the network - and
- 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.comreturns both twins; add&scope=internalor&scope=publicfor one.POST /domainswith 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:
- Once per scope - a Public and an Internal record of a name both exist; a second of either is refused, saying which scope.
- 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.
- 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.
- Subdomains - internal to internal parent, public to public parent; the Edit dialog lists the same scope first.
- 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.
- 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), sowww.example.comisexample.com. The test usesweb.for that reason.
Related
- Domains - Internal domains - for users, including split DNS
- Internal Domain Monitoring - Developer Guide - slice 3's other parts
- Internal monitoring in Domains - design - the plan this finishes
- Domains - Developer Guide