feat: make DDNS domain_name the effective domain across all services
Unit Tests / test (push) Successful in 11m35s

- ConfigManager.get_effective_domain(): returns domain_name when DDNS
  active (pic_ngo/cloudflare/duckdns), domain otherwise. Used by all
  public-facing services so they use the real registered FQDN.
- ConfigManager.get_internal_domain(): always returns _identity.domain
  (CoreDNS zone name, dnsmasq, cell-link invites — stays internal).
- Silent migration: if domain_mode != lan and domain is generic "cell",
  auto-set to {cell_name}.local for unique CoreDNS zone naming.
- caddy_manager: fix custom_domain bug — cloudflare/http01 modes were
  reading identity.get('custom_domain') which never exists; now reads
  domain_name correctly.
- routes/config, app: expose effective_domain in GET /api/config and
  /api/status responses.
- email_manager, routes/email: use get_effective_domain() for
  OVERRIDE_HOSTNAME, POSTMASTER_ADDRESS, and new-user email defaults.
- ServiceBus.IDENTITY_CHANGED event: emitted from PUT /api/config and
  POST /api/ddns/register after identity writes; caddy_manager and
  email_manager subscribe to regenerate config automatically.
- Settings.jsx: hide Local Domain input in non-LAN modes; show
  read-only effective_domain with "managed by DDNS" badge and an
  Advanced toggle for the internal CoreDNS zone name.
- 11 new test classes covering all new helpers, event subscriptions,
  caddy/email handlers, and the custom_domain fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 02:48:47 -04:00
parent 393d56d4ca
commit 1f016de855
13 changed files with 403 additions and 25 deletions
+51 -11
View File
@@ -433,6 +433,8 @@ function Settings() {
const [identity, setIdentity] = useState({ cell_name: '', domain: '', ip_range: '' });
const [identityDirty, setIdentityDirty] = useState(false);
const [loadedCellName, setLoadedCellName] = useState('');
const [effectiveDomain, setEffectiveDomain] = useState('');
const [showAdvancedZone, setShowAdvancedZone] = useState(false);
// DDNS
const [domainMode, setDomainMode] = useState('lan');
@@ -477,6 +479,7 @@ function Settings() {
ip_range: cfg.ip_range || '',
});
setLoadedCellName(cfg.cell_name || '');
setEffectiveDomain(cfg.effective_domain || cfg.domain_name || cfg.domain || '');
setIdentityDirty(false);
setDomainMode(cfg.domain_mode || 'lan');
setDomainName(cfg.domain_name || '');
@@ -514,9 +517,11 @@ function Settings() {
? 'Cell name must be 255 characters or fewer'
: (!identity.cell_name ? 'Cell name is required' : null);
const domainError = identity.domain && identity.domain.length > 255
? 'Domain must be 255 characters or fewer'
: (!identity.domain ? 'Domain is required' : null);
const domainError = domainMode !== 'lan'
? null
: (identity.domain && identity.domain.length > 255
? 'Domain must be 255 characters or fewer'
: (!identity.domain ? 'Domain is required' : null));
// pic_ngo availability check — fires 900ms after cell_name changes
const picAvailTimerRef = useRef(null);
@@ -553,6 +558,7 @@ function Settings() {
// Refresh to get updated domain_name after DDNS registration
const cfgRes = await cellAPI.getConfig();
setDomainName(cfgRes.data.domain_name || '');
setEffectiveDomain(cfgRes.data.effective_domain || cfgRes.data.domain_name || cfgRes.data.domain || '');
setDdnsHasToken(cfgRes.data.ddns?.has_token || false);
refreshConfig();
} catch (err) {
@@ -867,14 +873,48 @@ function Settings() {
<p className="mt-1 text-xs text-gray-400">External: <span className="font-mono">{identity.cell_name || '…'}.pic.ngo</span></p>
)}
</Field>
<Field label="Local Domain" error={domainError}>
<TextInput
value={identity.domain}
onChange={(v) => { setIdentity((i) => ({ ...i, domain: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }}
placeholder="cell"
maxLength={255}
/>
</Field>
{domainMode === 'lan' ? (
<Field label="Local Domain" error={domainError}>
<TextInput
value={identity.domain}
onChange={(v) => { setIdentity((i) => ({ ...i, domain: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }}
placeholder="cell"
maxLength={255}
/>
</Field>
) : (
<Field label="Cell Domain">
<div className="rounded-lg bg-gray-50 border border-gray-200 px-3 py-2.5 flex items-center justify-between">
<div>
<p className="text-xs font-medium text-gray-500 mb-0.5">Cell Domain</p>
<p className="text-sm font-mono text-gray-800">{effectiveDomain || `${identity.cell_name}.pic.ngo`}</p>
</div>
<span className="text-xs text-gray-400">managed by DDNS</span>
</div>
<div className="mt-1">
<button
type="button"
onClick={() => setShowAdvancedZone((v) => !v)}
className="text-xs text-gray-400 hover:text-gray-600 flex items-center gap-1"
>
{showAdvancedZone ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
Advanced
</button>
{showAdvancedZone && (
<div className="mt-2 pl-1 border-l-2 border-gray-100">
<Field label="Internal zone name (advanced)" error={identity.domain && identity.domain.length > 255 ? 'Domain must be 255 characters or fewer' : null} hint="Used for LAN DNS — most users should not change this">
<TextInput
value={identity.domain}
onChange={(v) => { setIdentity((i) => ({ ...i, domain: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }}
placeholder="cell"
maxLength={255}
/>
</Field>
</div>
)}
</div>
</Field>
)}
<Field label="IP Range" hint="Docker bridge subnet" error={ipRangeError}>
<TextInput
value={identity.ip_range}