Skip to content

Commit b6e084b

Browse files
Release v1.0.2: companion HTTPS records for Technitium
2 parents b3d9140 + b31bb90 commit b6e084b

18 files changed

Lines changed: 592 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.0.2] - 2026-03-31
11+
12+
### Added
13+
- **Companion HTTPS Records (Technitium)**: Auto-creates HTTPS (SVCB Type 65)
14+
companion records alongside A/AAAA/CNAME records to prevent ECH fallback
15+
errors in split-horizon DNS environments (#158)
16+
- Enabled by default (`AUTO_HTTPS_RECORDS=true`); set `false` to disable
17+
- Configurable ALPN protocol via `AUTO_HTTPS_ALPN` (default: `h2`)
18+
- Skips creation if HTTPS record already exists (safe for manual records)
19+
- Lifecycle-managed: companion record deleted when parent record is removed
20+
- **HTTPS Record Type**: Added `RecordTypeHTTPS` with `HTTPSData` struct to
21+
provider type system
22+
- **ECH Troubleshooting**: FAQ entry for Firefox/Chrome ECH connection failures
23+
24+
### Documentation
25+
- **Companion HTTPS Guide**: Full section in Technitium provider docs with
26+
Why/What/Behavior/Configuration subsections
27+
- **Split-Horizon Tip**: Added companion HTTPS recommendation to split-horizon
28+
deployment guide
29+
- **Config Example**: Added `auto_https_records` and `auto_https_alpn` to
30+
example configuration file
31+
1032
## [1.0.1] - 2026-03-30
1133

1234
### Documentation

docs/config.example.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ providers:
117117
url: http://dns.example.com:5380
118118
token: ${TECHNITIUM_TOKEN} # env var interpolation
119119
zone: internal.example.com
120+
# auto_https_records: true # Companion HTTPS records (enabled by default)
121+
# auto_https_alpn: h2 # ALPN protocol for companion records
120122

121123
# Public DNS using Cloudflare
122124
- name: public

docs/configuration/environment.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ Replace `{NAME}` with your instance name. For example, instance `internal-dns` u
136136

137137
See the individual provider documentation for complete settings:
138138

139-
- [Technitium](../providers/technitium.md)
139+
- [Technitium](../providers/technitium.md) — includes companion HTTPS record options
140140
- [Cloudflare](../providers/cloudflare.md)
141141
- [RFC 2136](../providers/rfc2136.md)
142142
- [Pi-hole](../providers/pihole.md)

docs/deployment/split-horizon.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ When container `app.example.com` starts:
5454
- Internal DNS → `A` record → `192.0.2.100`
5555
- External DNS → `CNAME` record → `tunnel.example.com`
5656

57+
!!! tip "Companion HTTPS Records"
58+
When using Technitium for internal DNS, dnsweaver automatically creates companion HTTPS (SVCB) records alongside A/CNAME records. This prevents ECH (Encrypted Client Hello) fallback errors that commonly occur in split-horizon setups where external DNS (e.g., Cloudflare) provides HTTPS records but internal DNS doesn't. See [Technitium — Companion HTTPS Records](../providers/technitium.md#companion-https-records) for details.
59+
5760
## Internal-Only Services
5861

5962
Some services should only be accessible internally. Use exclusion patterns:

docs/faq.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,18 @@ Changes are logged but not applied to DNS providers.
173173

174174
## Troubleshooting
175175

176+
### Firefox or Chrome fails to connect to internal services (ECH errors)
177+
178+
Modern browsers use ECH (Encrypted Client Hello) when HTTPS records exist in public DNS. If your internal DNS zone lacks matching HTTPS records, browsers may fail with connection errors or experience delays.
179+
180+
**Solution:** dnsweaver's Technitium provider automatically creates companion HTTPS records by default. If you've disabled this, re-enable it:
181+
182+
```yaml
183+
- DNSWEAVER_TECHNITIUM_AUTO_HTTPS_RECORDS=true
184+
```
185+
186+
See [Technitium — Companion HTTPS Records](providers/technitium.md#companion-https-records) for details.
187+
176188
### "No matching providers for hostname"
177189

178190
The extracted hostname doesn't match any provider's domain patterns. Check:

docs/providers/technitium.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ environment:
3737
| `EXCLUDE_DOMAINS` | No | - | Patterns to exclude |
3838
| `TTL` | No | `300` | Record TTL in seconds |
3939
| `INSECURE_SKIP_VERIFY` | No | `false` | Skip TLS certificate verification |
40+
| `AUTO_HTTPS_RECORDS` | No | `true` | Auto-create companion HTTPS records (see below) |
41+
| `AUTO_HTTPS_ALPN` | No | `h2` | ALPN protocol for companion HTTPS records |
4042

4143
## Getting an API Token
4244

@@ -144,3 +146,48 @@ For self-signed certificates, either:
144146
```yaml
145147
- DNSWEAVER_TECHNITIUM_INSECURE_SKIP_VERIFY=true
146148
```
149+
150+
## Companion HTTPS Records
151+
152+
By default, dnsweaver automatically creates companion HTTPS (SVCB Type 65) records whenever it creates an A, AAAA, or CNAME record in Technitium. This prevents **ECH (Encrypted Client Hello) fallback errors** that commonly affect split-horizon DNS environments.
153+
154+
### Why This Exists
155+
156+
Modern browsers (Firefox 128+, Chrome 131+) use ECH to encrypt the SNI during TLS handshakes. When a public domain has HTTPS records (provided by CDNs like Cloudflare), but your internal DNS zone doesn't, browsers may fail to connect or experience delays trying to use ECH parameters that don't apply internally.
157+
158+
The companion HTTPS record tells browsers "this host speaks HTTP/2 over TLS" without ECH, preventing the fallback error.
159+
160+
### What Gets Created
161+
162+
For each A/AAAA/CNAME record, dnsweaver creates:
163+
164+
```
165+
app.example.com 300 IN HTTPS 1 . alpn="h2"
166+
```
167+
168+
- **Priority 1** (ServiceMode) — overrides any inherited ECH parameters
169+
- **Target `.`** (self) — the record's own hostname, per RFC 9460
170+
- **ALPN `h2`** — HTTP/2 over TLS (configurable)
171+
172+
### Behavior
173+
174+
- **Enabled by default** — no configuration needed
175+
- **Safe** — skips creation if an HTTPS record already exists (won't overwrite manual records)
176+
- **Lifecycle-managed** — companion records are deleted when the parent record is removed
177+
- **Idempotent** — duplicate creation attempts are handled gracefully
178+
179+
### Configuration
180+
181+
```yaml
182+
# Disable companion HTTPS records (not recommended for split-horizon setups)
183+
- DNSWEAVER_TECHNITIUM_AUTO_HTTPS_RECORDS=false
184+
185+
# Change the ALPN protocol (default: h2)
186+
- DNSWEAVER_TECHNITIUM_AUTO_HTTPS_ALPN=h2,h3
187+
```
188+
189+
!!! tip
190+
If you use Cloudflare for external DNS and Technitium for internal DNS (a common split-horizon setup), companion HTTPS records are essential. Cloudflare provides HTTPS records automatically on their side — Technitium needs them too.
191+
192+
!!! note
193+
This feature only applies to the Technitium provider. Other providers either handle HTTPS records automatically (Cloudflare) or don't support them (Pi-hole, dnsmasq).

internal/config/validate.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ func validateTargetRecordType(inst *ProviderInstanceConfig) []*ConfigError {
139139
}
140140
case provider.RecordTypeTXT, provider.RecordTypeSRV:
141141
// Flexible targets, no validation needed
142+
case provider.RecordTypeHTTPS:
143+
// HTTPS records are managed automatically as companion records; no target validation needed
142144
}
143145

144146
return errs

internal/reconciler/cache.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ func (c *recordCache) getExistingRecords(providerName, hostname string) ([]provi
7777
var filtered []provider.Record
7878
for _, r := range records {
7979
switch r.Type {
80-
case provider.RecordTypeA, provider.RecordTypeAAAA, provider.RecordTypeCNAME, provider.RecordTypeSRV:
80+
case provider.RecordTypeA, provider.RecordTypeAAAA, provider.RecordTypeCNAME, provider.RecordTypeSRV, provider.RecordTypeHTTPS:
8181
filtered = append(filtered, r)
8282
case provider.RecordTypeTXT:
8383
// Skip TXT records (ownership markers)
@@ -106,7 +106,7 @@ func (c *recordCache) getAllRecordsForHostname(providerName, hostname string) ([
106106
var filtered []provider.Record
107107
for _, r := range records {
108108
switch r.Type {
109-
case provider.RecordTypeA, provider.RecordTypeAAAA, provider.RecordTypeCNAME, provider.RecordTypeSRV:
109+
case provider.RecordTypeA, provider.RecordTypeAAAA, provider.RecordTypeCNAME, provider.RecordTypeSRV, provider.RecordTypeHTTPS:
110110
filtered = append(filtered, r)
111111
case provider.RecordTypeTXT:
112112
// Skip TXT records (ownership markers)

internal/reconciler/orphan.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ func (r *Reconciler) deleteManagedForProvider(ctx context.Context, hostname stri
364364
for _, rec := range allRecords {
365365
if rec.Hostname == hostname {
366366
switch rec.Type {
367-
case provider.RecordTypeA, provider.RecordTypeAAAA, provider.RecordTypeCNAME, provider.RecordTypeSRV:
367+
case provider.RecordTypeA, provider.RecordTypeAAAA, provider.RecordTypeCNAME, provider.RecordTypeSRV, provider.RecordTypeHTTPS:
368368
recordsToDelete = append(recordsToDelete, rec)
369369
case provider.RecordTypeTXT:
370370
// Skip TXT records (ownership markers handled separately)
@@ -478,7 +478,7 @@ func (r *Reconciler) deleteCacheOnlyForProvider(ctx context.Context, hostname st
478478
for _, rec := range allRecords {
479479
if rec.Hostname == hostname {
480480
switch rec.Type {
481-
case provider.RecordTypeA, provider.RecordTypeAAAA, provider.RecordTypeCNAME, provider.RecordTypeSRV:
481+
case provider.RecordTypeA, provider.RecordTypeAAAA, provider.RecordTypeCNAME, provider.RecordTypeSRV, provider.RecordTypeHTTPS:
482482
recordsToDelete = append(recordsToDelete, rec)
483483
case provider.RecordTypeTXT:
484484
// Skip TXT records

internal/reconciler/orphan_legacy_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ func (r *Reconciler) deleteFromCache(ctx context.Context, hostname string, cache
7272
for _, rec := range allRecords {
7373
if rec.Hostname == hostname {
7474
switch rec.Type {
75-
case provider.RecordTypeA, provider.RecordTypeAAAA, provider.RecordTypeCNAME, provider.RecordTypeSRV:
75+
case provider.RecordTypeA, provider.RecordTypeAAAA, provider.RecordTypeCNAME, provider.RecordTypeSRV, provider.RecordTypeHTTPS:
7676
recordsToDelete = append(recordsToDelete, rec)
7777
case provider.RecordTypeTXT:
7878
// Skip TXT records (ownership markers)
@@ -228,7 +228,7 @@ func (r *Reconciler) deleteWithOwnership(ctx context.Context, hostname string, c
228228
for _, rec := range allRecords {
229229
if rec.Hostname == hostname {
230230
switch rec.Type {
231-
case provider.RecordTypeA, provider.RecordTypeAAAA, provider.RecordTypeCNAME, provider.RecordTypeSRV:
231+
case provider.RecordTypeA, provider.RecordTypeAAAA, provider.RecordTypeCNAME, provider.RecordTypeSRV, provider.RecordTypeHTTPS:
232232
recordsToDelete = append(recordsToDelete, rec)
233233
case provider.RecordTypeTXT:
234234
// Skip TXT records (ownership markers)

0 commit comments

Comments
 (0)