Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/codeql-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ jobs:
go-version-file: 'go.mod'

- name: Initialize CodeQL
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2

- name: Autobuild
uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
20 changes: 20 additions & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
# Version 2.1.17
- Added support for PQDNSCrypt (DNSCrypt 2026), using post-quantum cryptography
with compatible DNSCrypt servers to protect query confidentiality against future
quantum computers. It is enabled by default and can be disabled with the new
`pqdnscrypt` setting.
- Key material is now rotated when the local network changes, reducing
linkability across network changes.
- Fixed a cache issue that could corrupt cached responses or cause data
races under concurrent use.
- The `netprobe_timeout` configuration setting is now honored instead of
being silently overridden by its command-line default.
- Forwarded queries now correctly fall back to TCP after a truncated UDP
response, and SOCKS-proxied anonymized DNS exchanges are sent to the relay.
- HTTP/3 connections now retry after transient failures, and bootstrap
resolution succeeds when at least one address was obtained.
- Cloaking rules that resolve through a rule ending in an IP address are no
longer incorrectly rejected as recursive. Actual loops are still rejected.
- The monitoring dashboard now resolves API paths against the page origin,
allowing it to work correctly when hosted under a path.

# Version 2.1.16
- Dashboard HTML pages are no longer cached, preventing stale content from
being served after upgrades.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

## Overview

A flexible DNS proxy, with support for modern encrypted DNS protocols such as [DNSCrypt v2](https://dnscrypt.info/protocol), [DNS-over-HTTPS](https://www.rfc-editor.org/rfc/rfc8484.txt), [Anonymized DNSCrypt](https://github.com/DNSCrypt/dnscrypt-protocol/blob/master/ANONYMIZED-DNSCRYPT.txt) and [ODoH (Oblivious DoH)](https://github.com/DNSCrypt/dnscrypt-resolvers/blob/master/v3/odoh-servers.md).
A flexible DNS proxy, with support for modern encrypted DNS protocols such as [DNSCrypt v2](https://dnscrypt.info/protocol) including Post-Quantum DNSCrypt (DNSCrypt 2026), [DNS-over-HTTPS](https://www.rfc-editor.org/rfc/rfc8484.txt), [Anonymized DNSCrypt](https://github.com/DNSCrypt/dnscrypt-protocol/blob/master/ANONYMIZED-DNSCRYPT.txt) and [ODoH (Oblivious DoH)](https://github.com/DNSCrypt/dnscrypt-resolvers/blob/master/v3/odoh-servers.md).

* **[dnscrypt-proxy documentation](https://dnscrypt.info/doc) ← Start here**
* [DNSCrypt project home page](https://dnscrypt.info/)
Expand All @@ -22,7 +22,7 @@ Available as source code and pre-built binaries for most operating systems and a

## Features

* DNS traffic encryption and authentication. Supports DNS-over-HTTPS (DoH) using TLS 1.3 and QUIC, DNSCrypt, Anonymized DNS and ODoH
* DNS traffic encryption and authentication. Supports DNS-over-HTTPS (DoH) using TLS 1.3 and QUIC, PQDNSCrypt, DNSCrypt, Anonymized DNS and ODoH
* Client IP addresses can be hidden using Tor, SOCKS proxies or Anonymized DNS relays
* DNS query monitoring, with separate log files for regular and suspicious queries
* Filtering: block ads, malware, and other unwanted content. Compatible with all DNS services
Expand Down
2 changes: 2 additions & 0 deletions dnscrypt-proxy/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ type Config struct {
CertRefreshConcurrency int `toml:"cert_refresh_concurrency"`
CertRefreshDelay int `toml:"cert_refresh_delay"`
CertIgnoreTimestamp bool `toml:"cert_ignore_timestamp"`
PQDNSCrypt bool `toml:"pqdnscrypt"`
EphemeralKeys bool `toml:"dnscrypt_ephemeral_keys"`
LBStrategy string `toml:"lb_strategy"`
LBEstimator bool `toml:"lb_estimator"`
Expand Down Expand Up @@ -131,6 +132,7 @@ func newConfig() Config {
HTTP3: false,
HTTP3Probe: false,
CertIgnoreTimestamp: false,
PQDNSCrypt: true,
EphemeralKeys: false,
Cache: true,
CacheSize: 512,
Expand Down
1 change: 1 addition & 0 deletions dnscrypt-proxy/config_loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ func configureServerParams(proxy *Proxy, config *Config) {
proxy.certRefreshDelay = time.Duration(Max(60, config.CertRefreshDelay)) * time.Minute
proxy.certRefreshDelayAfterFailure = 10 * time.Second
proxy.certIgnoreTimestamp = config.CertIgnoreTimestamp
proxy.pqDNSCrypt = config.PQDNSCrypt
proxy.ephemeralKeys = config.EphemeralKeys
proxy.monitoringUI = config.MonitoringUI
}
Expand Down
4 changes: 4 additions & 0 deletions dnscrypt-proxy/dnscrypt_certs.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ func FetchCurrentDNSCryptCert(
continue
}
isPQ := cryptoConstruction == XWingPQ
if isPQ && !proxy.pqDNSCrypt {
dlog.Debugf("[%v] ignoring post-quantum certificate, disabled in the configuration", *serverName)
continue
}
if isPQ && len(binCert) < 1320 {
dlog.Warnf("[%v] PQ certificate too short", *serverName)
continue
Expand Down
12 changes: 10 additions & 2 deletions dnscrypt-proxy/example-dnscrypt-proxy.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ ipv4_servers = true
# Use servers reachable over IPv6 -- Do not enable if you don't have IPv6 connectivity
ipv6_servers = false

# Use servers implementing the DNSCrypt protocol
# Use servers implementing the DNSCrypt and PQDNSCrypt protocols
dnscrypt_servers = true

# Use servers implementing the DNS-over-HTTPS protocol
Expand Down Expand Up @@ -275,13 +275,21 @@ cert_refresh_delay = 240
# cert_ignore_timestamp = false


## DNSCrypt: Create a new, unique key for every single DNS query
## DNSCrypt: Create a new, unique X25519 key pair for every single DNS query
## This may improve privacy but can also have a significant impact on CPU usage
## Only enable if you don't have a lot of network load

# dnscrypt_ephemeral_keys = false


## PQDNSCrypt (DNSCrypt 2026): use post-quantum cryptography when supported
## by the server.
## This protects the confidentiality of your queries against future quantum
## computers ("harvest now, decrypt later").

# pqdnscrypt = true


## DoH: Disable TLS session tickets - increases privacy but also latency

# tls_disable_session_tickets = false
Expand Down
2 changes: 1 addition & 1 deletion dnscrypt-proxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
)

const (
AppVersion = "2.1.16"
AppVersion = "2.1.17"
DefaultConfigFileName = "dnscrypt-proxy.toml"
)

Expand Down
25 changes: 25 additions & 0 deletions dnscrypt-proxy/monitoring_ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ type MonitoringUIConfig struct {
PrometheusPath string `toml:"prometheus_path"` // Path for Prometheus metrics endpoint (default: /metrics)
}

const maxTopDomains = 1000

// MetricsCollector - Collects and stores metrics for the monitoring UI
type MetricsCollector struct {
// Split locks for better concurrency
Expand Down Expand Up @@ -361,6 +363,9 @@ func (ui *MonitoringUI) UpdateMetrics(pluginsState PluginsState, msg *dns.Msg) {
// Store domain name directly - no sanitization needed for internal metrics
domainName := pluginsState.qName
mc.domainMutex.Lock()
if _, found := mc.topDomains[domainName]; !found && len(mc.topDomains) >= maxTopDomains {
mc.pruneTopDomainsLocked()
}
mc.topDomains[domainName]++
mc.domainMutex.Unlock()
}
Expand Down Expand Up @@ -449,6 +454,26 @@ func (ui *MonitoringUI) UpdateMetrics(pluginsState PluginsState, msg *dns.Msg) {
ui.scheduleBroadcast()
}

func (mc *MetricsCollector) pruneTopDomainsLocked() {
type domainCount struct {
domain string
count uint64
}
counts := make([]domainCount, 0, len(mc.topDomains))
for domain, hits := range mc.topDomains {
counts = append(counts, domainCount{domain, hits})
}
sort.Slice(counts, func(i, j int) bool {
if counts[i].count != counts[j].count {
return counts[i].count > counts[j].count
}
return counts[i].domain < counts[j].domain
})
for _, dc := range counts[maxTopDomains/2:] {
delete(mc.topDomains, dc.domain)
}
}

// generatePrometheusMetrics - Generates Prometheus-formatted metrics
func (mc *MetricsCollector) generatePrometheusMetrics() string {
if !mc.prometheusEnabled {
Expand Down
9 changes: 7 additions & 2 deletions dnscrypt-proxy/oblivious_doh.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,11 +185,16 @@ func (q ODoHQuery) decryptResponse(response []byte) ([]byte, error) {
}

responseLength := binary.BigEndian.Uint16(responsePlaintext[0:2])
if int(responseLength)+2 > len(responsePlaintext) {
paddingOffset := 2 + int(responseLength)
if paddingOffset+2 > len(responsePlaintext) {
return nil, fmt.Errorf("Malformed response")
}
paddingLength := int(binary.BigEndian.Uint16(responsePlaintext[paddingOffset : paddingOffset+2]))
if paddingOffset+2+paddingLength != len(responsePlaintext) {
return nil, fmt.Errorf("Malformed response")
}
valid := 1
for i := 4 + int(responseLength); i < len(responsePlaintext); i++ {
for i := paddingOffset + 2; i < len(responsePlaintext); i++ {
valid &= subtle.ConstantTimeByteEq(responsePlaintext[i], 0x00)
}
if valid != 1 {
Expand Down
62 changes: 49 additions & 13 deletions dnscrypt-proxy/pq.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,23 +131,26 @@ func pqEncapsulate(pk []byte) (kemSS, ct []byte, err error) {
return xwing.Encapsulate(pk, nil)
}

// pqResumptionState holds the most recent resumption ticket for a server.
type pqResumptionState struct {
// pqSessionState holds a server's reusable PQ key material.
type pqSessionState struct {
mu sync.Mutex
ticket []byte
resumeSecret [32]byte
expiry time.Time
epoch uint64
encap []byte
encapKey [32]byte
encapEpoch uint64
}

func newPqResumptionState(c CryptoConstruction) *pqResumptionState {
func newPqSessionState(c CryptoConstruction) *pqSessionState {
if c != XWingPQ {
return nil
}
return &pqResumptionState{}
return &pqSessionState{}
}

func (s *pqResumptionState) store(ticket []byte, resumeSecret [32]byte, expiry time.Time, epoch uint64) {
func (s *pqSessionState) store(ticket []byte, resumeSecret [32]byte, expiry time.Time, epoch uint64) {
if s == nil {
return
}
Expand All @@ -159,7 +162,7 @@ func (s *pqResumptionState) store(ticket []byte, resumeSecret [32]byte, expiry t
s.epoch = epoch
}

func (s *pqResumptionState) get(currentEpoch uint64) (ticket []byte, resumeSecret [32]byte, ok bool) {
func (s *pqSessionState) get(currentEpoch uint64) (ticket []byte, resumeSecret [32]byte, ok bool) {
if s == nil {
return nil, [32]byte{}, false
}
Expand All @@ -177,8 +180,37 @@ func (s *pqResumptionState) get(currentEpoch uint64) (ticket []byte, resumeSecre
return append([]byte(nil), s.ticket...), s.resumeSecret, true
}

func (s *pqSessionState) getCachedEncapsulation(currentEpoch uint64) (ct []byte, key [32]byte, ok bool) {
if s == nil {
return nil, [32]byte{}, false
}
s.mu.Lock()
defer s.mu.Unlock()
if s.encap == nil {
return nil, [32]byte{}, false
}
if s.encapEpoch != currentEpoch {
s.encap = nil
s.encapKey = [32]byte{}
return nil, [32]byte{}, false
}
return append([]byte(nil), s.encap...), s.encapKey, true
}

func (s *pqSessionState) storeEncapsulation(ct []byte, key [32]byte, epoch uint64) {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.encap = append([]byte(nil), ct...)
s.encapKey = key
s.encapEpoch = epoch
}

// encryptPQ builds a PQ query: a resumed query when a valid ticket is held,
// otherwise a query that carries a fresh X-Wing ciphertext.
// otherwise a query that carries an X-Wing ciphertext, reusing the cached
// encapsulation when one is available for the current network epoch.
func (proxy *Proxy) encryptPQ(
serverInfo *ServerInfo,
packet []byte,
Expand All @@ -192,7 +224,7 @@ func (proxy *Proxy) encryptPQ(
}
copy(nonce, clientNonce)

if ticket, resumeSecret, ok := serverInfo.pqResumption.get(queryEpoch); ok {
if ticket, resumeSecret, ok := serverInfo.pqSession.get(queryEpoch); ok {
key := pqResumedSharedKey(resumeSecret, serverInfo.MagicQuery, clientNonce, ticket)
padded := pqPad(packet, 256)
ct := xsecretbox.Seal(nil, nonce, padded, key[:])
Expand All @@ -207,11 +239,15 @@ func (proxy *Proxy) encryptPQ(
return &key, out, clientNonce, queryEpoch, nil
}

kemSS, ctKem, err := pqEncapsulate(serverInfo.PqPublicKey)
if err != nil {
return nil, nil, nil, queryEpoch, err
ctKem, key, ok := serverInfo.pqSession.getCachedEncapsulation(queryEpoch)
if !ok {
var kemSS []byte
if kemSS, ctKem, err = pqEncapsulate(serverInfo.PqPublicKey); err != nil {
return nil, nil, nil, queryEpoch, err
}
key = pqDeriveSharedKey(kemSS, serverInfo.MagicQuery, serverInfo.PqCertContext, ctKem)
serverInfo.pqSession.storeEncapsulation(ctKem, key, queryEpoch)
}
key := pqDeriveSharedKey(kemSS, serverInfo.MagicQuery, serverInfo.PqCertContext, ctKem)
padded := pqPad(packet, 64)
ct := xsecretbox.Seal(nil, nonce, padded, key[:])
out := make([]byte, 0, PQClientMagicLen+len(ctKem)+HalfNonceSize+len(ct))
Expand Down Expand Up @@ -269,6 +305,6 @@ func (proxy *Proxy) pqProcessControl(
}
resumeSecret := pqResumeSecret(*sharedKey, serverInfo.MagicQuery, clientNonce)
expiry := time.Now().Add(time.Duration(lifetime) * time.Second)
serverInfo.pqResumption.store(ticket, resumeSecret, expiry, queryEpoch)
serverInfo.pqSession.store(ticket, resumeSecret, expiry, queryEpoch)
dlog.Debugf("[%v] stored a PQ resumption ticket (lifetime %ds)", serverInfo.Name, lifetime)
}
Loading