Created
July 4, 2026 19:43
-
-
Save workturnedplay/d6173693604c78dc3ef6b472eee82ebc to your computer and use it in GitHub Desktop.
git diff, wild! don't forget to do this: $ git config --global diff.algorithm histogram //HEAD was commit: 6320995fba1b377e44a3b37bdc1ca324dcb3d34e in dnsbollocks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| diff --git a/internal/dnsbollocks/platform_windows.go b/internal/dnsbollocks/platform_windows.go | |
| index c514ab5..d5c01d8 100644 | |
| --- a/internal/dnsbollocks/platform_windows.go | |
| +++ b/internal/dnsbollocks/platform_windows.go | |
| @@ -2570,480 +2570,20 @@ func (s *Server) loadMainConfig() error { | |
| s.fileWriter.SetExtraSafety(resolvedCfg.ExtraSafety) //uses newly loaded config settings ie. cfg.ExtraSafety | |
| - tagWebUIUseTLS := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUIUseTLS)) | |
| - tagWebUIForceTLS := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUIForceTLSOnNonLocalhost)) | |
| - tagListenUI := getJSONTagByOffset(unsafe.Offsetof(Config{}.ListenUI)) | |
| - | |
| - boundToLoopback := isLoopbackBindHost(resolvedCfg.ListenUI) | |
| - | |
| - switch { | |
| - case !resolvedCfg.WebUIUseTLS && !boundToLoopback && resolvedCfg.WebUIForceTLSOnNonLocalhost: | |
| - log.Warn(tagWebUIUseTLS+" was false while "+tagListenUI+" is bound off-loopback; "+ | |
| - "auto-promoting to TLS so the bcrypt-checked WebUI password isn't sent as plaintext(thus sniffable) "+ | |
| - "Basic-Auth over the network. Set "+tagWebUIForceTLS+" to false to override.", | |
| - slog.String("listen_ui", resolvedCfg.ListenUI)) | |
| - resolvedCfg.WebUIUseTLS = true | |
| - rawCfg.WebUIUseTLS = true | |
| - shouldSaveConfig = true //hmm, self-heals?! | |
| - | |
| - case !resolvedCfg.WebUIUseTLS && !boundToLoopback: | |
| - log.Error(tagWebUIUseTLS+" and "+tagWebUIForceTLS+" are both false while bound off-loopback; "+ | |
| - "the WebUI password will be sent in PLAINTEXT (Basic-Auth is base64, not encryption) "+ | |
| - "to anyone who can observe this network segment.", | |
| - slog.String("listen_ui", resolvedCfg.ListenUI)) | |
| - | |
| - case !resolvedCfg.WebUIUseTLS && boundToLoopback: | |
| - log.Warn(tagWebUIUseTLS+" is false. Even on loopback, Basic-Auth sends the password as base64 "+ | |
| - "(not encrypted) to any other local process/user that can observe loopback traffic.", | |
| - slog.String("listen_ui", resolvedCfg.ListenUI)) | |
| - } | |
| - | |
| - // ========================================================================= | |
| - // Group 1: WebUI Server Timeouts & Rate Limits (Refactor-safe) | |
| - // ========================================================================= | |
| - tagWebUIReadHeader := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUIReadHeaderTimeoutSec)) | |
| - if was := resolvedCfg.WebUIReadHeaderTimeoutSec; was <= 0 { | |
| - fallback := defaultConfig.WebUIReadHeaderTimeoutSec | |
| - resolvedCfg.WebUIReadHeaderTimeoutSec = fallback | |
| - rawCfg.WebUIReadHeaderTimeoutSec = fallback | |
| - log.Warn(tagWebUIReadHeader+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - tagWebUIRead := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUIReadTimeoutSec)) | |
| - if was := resolvedCfg.WebUIReadTimeoutSec; was <= 0 { | |
| - fallback := defaultConfig.WebUIReadTimeoutSec | |
| - resolvedCfg.WebUIReadTimeoutSec = fallback | |
| - rawCfg.WebUIReadTimeoutSec = fallback | |
| - log.Warn(tagWebUIRead+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - tagWebUIWrite := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUIWriteTimeoutSec)) | |
| - if was := resolvedCfg.WebUIWriteTimeoutSec; was <= 0 { | |
| - fallback := defaultConfig.WebUIWriteTimeoutSec | |
| - resolvedCfg.WebUIWriteTimeoutSec = fallback | |
| - rawCfg.WebUIWriteTimeoutSec = fallback | |
| - log.Warn(tagWebUIWrite+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - tagWebUIIdle := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUIIdleTimeoutSec)) | |
| - if was := resolvedCfg.WebUIIdleTimeoutSec; was <= resolvedCfg.WebUIReadTimeoutSec { | |
| - fallback := resolvedCfg.WebUIReadTimeoutSec * 2 | |
| - resolvedCfg.WebUIIdleTimeoutSec = fallback | |
| - rawCfg.WebUIIdleTimeoutSec = fallback | |
| - log.Warn(tagWebUIIdle+" clamped(to double the read timeout) to prevent aggressive keep-alive disconnects", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - tagWebUIMaxLoginFailures := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUIMaxLoginFailures)) | |
| - if was := resolvedCfg.WebUIMaxLoginFailures; was <= 0 { | |
| - fallback := defaultConfig.WebUIMaxLoginFailures | |
| - resolvedCfg.WebUIMaxLoginFailures = fallback | |
| - rawCfg.WebUIMaxLoginFailures = fallback | |
| - log.Warn(tagWebUIMaxLoginFailures+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - tagWebUILoginLockoutSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUILoginLockoutSec)) | |
| - if was := resolvedCfg.WebUILoginLockoutSec; was <= 0 { | |
| - fallback := defaultConfig.WebUILoginLockoutSec | |
| - resolvedCfg.WebUILoginLockoutSec = fallback | |
| - rawCfg.WebUILoginLockoutSec = fallback | |
| - log.Warn(tagWebUILoginLockoutSec+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - // ========================================================================= | |
| - // Group 2: Local DoH Server Timeouts | |
| - // ========================================================================= | |
| - tagDoHHeader := getJSONTagByOffset(unsafe.Offsetof(Config{}.LocalDoHReadHeaderTimeoutSec)) | |
| - if was := resolvedCfg.LocalDoHReadHeaderTimeoutSec; was <= 0 { | |
| - fallback := defaultConfig.LocalDoHReadHeaderTimeoutSec | |
| - resolvedCfg.LocalDoHReadHeaderTimeoutSec = fallback | |
| - rawCfg.LocalDoHReadHeaderTimeoutSec = fallback | |
| - log.Warn(tagDoHHeader+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - tagDoHRead := getJSONTagByOffset(unsafe.Offsetof(Config{}.LocalDoHReadTimeoutSec)) | |
| - if was := resolvedCfg.LocalDoHReadTimeoutSec; was <= 0 { | |
| - fallback := defaultConfig.LocalDoHReadTimeoutSec | |
| - resolvedCfg.LocalDoHReadTimeoutSec = fallback | |
| - rawCfg.LocalDoHReadTimeoutSec = fallback | |
| - log.Warn(tagDoHRead+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - tagDoHWrite := getJSONTagByOffset(unsafe.Offsetof(Config{}.LocalDoHWriteTimeoutSec)) | |
| - if was := resolvedCfg.LocalDoHWriteTimeoutSec; was <= 0 { | |
| - fallback := defaultConfig.LocalDoHWriteTimeoutSec | |
| - resolvedCfg.LocalDoHWriteTimeoutSec = fallback | |
| - rawCfg.LocalDoHWriteTimeoutSec = fallback | |
| - log.Warn(tagDoHWrite+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - tagDoHIdle := getJSONTagByOffset(unsafe.Offsetof(Config{}.LocalDoHIdleTimeoutSec)) | |
| - if was := resolvedCfg.LocalDoHIdleTimeoutSec; was <= resolvedCfg.LocalDoHReadTimeoutSec { | |
| - fallback := resolvedCfg.LocalDoHReadTimeoutSec * 2 | |
| - resolvedCfg.LocalDoHIdleTimeoutSec = fallback | |
| - rawCfg.LocalDoHIdleTimeoutSec = fallback | |
| - log.Warn(tagDoHIdle+" clamped(to double the read timeout) to prevent premature keep-alive drops", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - // ========================================================================= | |
| - // Validate Upstream HTTP Client Idle Connection Pools (Refactor-safe) | |
| - // ========================================================================= | |
| - // ========================================================================= | |
| - // Group 3: Upstream Client & Connection Pools | |
| - // ========================================================================= | |
| - tagUpstreamDialTimeoutSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.UpstreamDialTimeoutSec)) | |
| - if was := resolvedCfg.UpstreamDialTimeoutSec; was <= 0 { | |
| - fallback := defaultConfig.UpstreamDialTimeoutSec | |
| - resolvedCfg.UpstreamDialTimeoutSec = fallback | |
| - rawCfg.UpstreamDialTimeoutSec = fallback | |
| - log.Warn(tagUpstreamDialTimeoutSec+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - tagUpstreamClientTimeoutSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.UpstreamClientTimeoutSec)) | |
| - // Constraint A: Absolute lower bound check | |
| - if was := resolvedCfg.UpstreamClientTimeoutSec; was <= 0 { | |
| - fallback := defaultConfig.UpstreamClientTimeoutSec | |
| - resolvedCfg.UpstreamClientTimeoutSec = fallback | |
| - rawCfg.UpstreamClientTimeoutSec = fallback | |
| - log.Warn(tagUpstreamClientTimeoutSec+" clamped (prevents infinite hanging client connections)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - // Constraint B: Relational validation check (Sequential, never an 'else if') | |
| - if was := resolvedCfg.UpstreamClientTimeoutSec; was < resolvedCfg.UpstreamDialTimeoutSec { | |
| - fallback := resolvedCfg.UpstreamDialTimeoutSec | |
| - resolvedCfg.UpstreamClientTimeoutSec = fallback | |
| - rawCfg.UpstreamClientTimeoutSec = fallback | |
| - log.Warn(tagUpstreamClientTimeoutSec+" clamped (cannot be less than dial timeout "+tagUpstreamDialTimeoutSec+")", | |
| - slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - tagCertLogTimeoutSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.CertLogTimeoutSec)) | |
| - if was := resolvedCfg.CertLogTimeoutSec; was <= 0 { | |
| - fallback := defaultConfig.CertLogTimeoutSec | |
| - resolvedCfg.CertLogTimeoutSec = fallback | |
| - rawCfg.CertLogTimeoutSec = fallback | |
| - log.Warn(tagCertLogTimeoutSec+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - tagUpstreamRetryBackoffMs := getJSONTagByOffset(unsafe.Offsetof(Config{}.UpstreamRetryBackoffMs)) | |
| - if was := resolvedCfg.UpstreamRetryBackoffMs; was <= 0 { | |
| - fallback := defaultConfig.UpstreamRetryBackoffMs | |
| - resolvedCfg.UpstreamRetryBackoffMs = fallback | |
| - rawCfg.UpstreamRetryBackoffMs = fallback | |
| - log.Warn(tagUpstreamRetryBackoffMs+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - tagUpstreamRetriesPerQuery := getJSONTagByOffset(unsafe.Offsetof(Config{}.UpstreamRetriesPerQuery)) | |
| - if was := resolvedCfg.UpstreamRetriesPerQuery; was < 0 { | |
| - fallback := defaultConfig.UpstreamRetriesPerQuery | |
| - resolvedCfg.UpstreamRetriesPerQuery = fallback | |
| - rawCfg.UpstreamRetriesPerQuery = fallback | |
| - log.Warn(tagUpstreamRetriesPerQuery+" clamped (cannot be negative)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - tagUpstreamIdleConnTimeout := getJSONTagByOffset(unsafe.Offsetof(Config{}.UpstreamIdleConnTimeoutSec)) | |
| - if was := resolvedCfg.UpstreamIdleConnTimeoutSec; was <= 0 { | |
| - fallback := defaultConfig.UpstreamIdleConnTimeoutSec | |
| - resolvedCfg.UpstreamIdleConnTimeoutSec = fallback | |
| - rawCfg.UpstreamIdleConnTimeoutSec = fallback | |
| - log.Warn(tagUpstreamIdleConnTimeout+" clamped (connections stay open indefinitely or drop unpredictably)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - tagUpstreamMaxIdleConns := getJSONTagByOffset(unsafe.Offsetof(Config{}.UpstreamMaxIdleConns)) | |
| - if was := resolvedCfg.UpstreamMaxIdleConns; was <= 0 { | |
| - fallback := defaultConfig.UpstreamMaxIdleConns | |
| - resolvedCfg.UpstreamMaxIdleConns = fallback | |
| - rawCfg.UpstreamMaxIdleConns = fallback | |
| - log.Warn(tagUpstreamMaxIdleConns+" clamped (disables global keep-alive reuse)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - tagUpstreamMaxIdleConnsPerHost := getJSONTagByOffset(unsafe.Offsetof(Config{}.UpstreamMaxIdleConnsPerHost)) | |
| - // Constraint A: Absolute lower bound check | |
| - if was := resolvedCfg.UpstreamMaxIdleConnsPerHost; was <= 0 { | |
| - fallback := defaultConfig.UpstreamMaxIdleConnsPerHost | |
| - resolvedCfg.UpstreamMaxIdleConnsPerHost = fallback | |
| - rawCfg.UpstreamMaxIdleConnsPerHost = fallback | |
| - log.Warn(tagUpstreamMaxIdleConnsPerHost+" clamped (Go default of 2 severely throttles throughput)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - // Constraint B: Relational validation check (Sequential, never an 'else if') | |
| - if was := resolvedCfg.UpstreamMaxIdleConnsPerHost; was > resolvedCfg.UpstreamMaxIdleConns { | |
| - // Defensive check: Per-host pool limit can't realistically exceed global pool limit | |
| - fallback := resolvedCfg.UpstreamMaxIdleConns | |
| - resolvedCfg.UpstreamMaxIdleConnsPerHost = fallback | |
| - rawCfg.UpstreamMaxIdleConnsPerHost = fallback | |
| - log.Warn(tagUpstreamMaxIdleConnsPerHost+" clamped (cannot exceed "+tagUpstreamMaxIdleConns+")", | |
| - slog.Int("was", was), | |
| - slog.Int("clamp", fallback), | |
| - slog.Int(tagUpstreamMaxIdleConns, resolvedCfg.UpstreamMaxIdleConns), | |
| - ) | |
| - } | |
| - | |
| - // ========================================================================= | |
| - // Group 4: Local Client & Server Buffer Safeguards | |
| - // ========================================================================= | |
| - tagMaxConcurrentDNSTCPConns := getJSONTagByOffset(unsafe.Offsetof(Config{}.MaxConcurrentDNSTCPConns)) | |
| - if was := resolvedCfg.MaxConcurrentDNSTCPConns; was <= 0 { | |
| - fallback := defaultConfig.MaxConcurrentDNSTCPConns | |
| - resolvedCfg.MaxConcurrentDNSTCPConns = fallback | |
| - rawCfg.MaxConcurrentDNSTCPConns = fallback | |
| - log.Warn(tagMaxConcurrentDNSTCPConns+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - tagMaxConcurrentDNSUDPQueries := getJSONTagByOffset(unsafe.Offsetof(Config{}.MaxConcurrentDNSUDPQueries)) | |
| - if was := resolvedCfg.MaxConcurrentDNSUDPQueries; was <= 0 { | |
| - fallback := defaultConfig.MaxConcurrentDNSUDPQueries | |
| - resolvedCfg.MaxConcurrentDNSUDPQueries = fallback | |
| - rawCfg.MaxConcurrentDNSUDPQueries = fallback | |
| - log.Warn(tagMaxConcurrentDNSUDPQueries+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - tagClientTCPTimeoutSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.ClientTCPTimeoutSec)) | |
| - if was := resolvedCfg.ClientTCPTimeoutSec; was <= 0 { | |
| - fallback := defaultConfig.ClientTCPTimeoutSec | |
| - resolvedCfg.ClientTCPTimeoutSec = fallback | |
| - rawCfg.ClientTCPTimeoutSec = fallback | |
| - log.Warn(tagClientTCPTimeoutSec+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - tagDoHMaxRequestBodyBytes := getJSONTagByOffset(unsafe.Offsetof(Config{}.DoHMaxRequestBodyBytes)) | |
| - if was := resolvedCfg.DoHMaxRequestBodyBytes; was <= 0 { | |
| - fallback := defaultConfig.DoHMaxRequestBodyBytes | |
| - resolvedCfg.DoHMaxRequestBodyBytes = fallback | |
| - rawCfg.DoHMaxRequestBodyBytes = fallback | |
| - log.Warn(tagDoHMaxRequestBodyBytes+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - tagDNSUDPBufferSize := getJSONTagByOffset(unsafe.Offsetof(Config{}.DNSUDPBufferSize)) | |
| - if was := resolvedCfg.DNSUDPBufferSize; was < 512 || was > 65535 { | |
| - fallback := defaultConfig.DNSUDPBufferSize | |
| - resolvedCfg.DNSUDPBufferSize = fallback | |
| - rawCfg.DNSUDPBufferSize = fallback | |
| - log.Warn(tagDNSUDPBufferSize+" clamped (must be within standard Ethernet bounds 512-65535)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - // ========================================================================= | |
| - // Group 5: Core Engine Limits & Cache Operations | |
| - // ========================================================================= | |
| - tagGlobalRateQPS := getJSONTagByOffset(unsafe.Offsetof(Config{}.GlobalRateQPS)) | |
| - if was := resolvedCfg.GlobalRateQPS; was <= 0 { | |
| - fallback := defaultConfig.GlobalRateQPS | |
| - resolvedCfg.GlobalRateQPS = fallback | |
| - rawCfg.GlobalRateQPS = fallback | |
| - log.Warn(tagGlobalRateQPS+" clamped (must be greater than 0)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - tagGlobalBurstQPS := getJSONTagByOffset(unsafe.Offsetof(Config{}.GlobalBurstQPS)) | |
| - // Constraint A: Absolute lower bound check | |
| - if was := resolvedCfg.GlobalBurstQPS; was <= 0 { | |
| - fallback := defaultConfig.GlobalBurstQPS | |
| - resolvedCfg.GlobalBurstQPS = fallback | |
| - rawCfg.GlobalBurstQPS = fallback | |
| - log.Warn(tagGlobalBurstQPS+" clamped (must be greater than 0)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } // else if was < newCfg.GlobalRateQPS { | |
| - // Constraint B: Relational check (Executed sequentially, NEVER as an 'else if') | |
| - if was := resolvedCfg.GlobalBurstQPS; was < resolvedCfg.GlobalRateQPS { | |
| - fallback := resolvedCfg.GlobalRateQPS | |
| - resolvedCfg.GlobalBurstQPS = fallback | |
| - rawCfg.GlobalBurstQPS = fallback | |
| - log.Warn(tagGlobalBurstQPS+" clamped (cannot be less than "+tagGlobalRateQPS+")", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - tagClientRateQPS := getJSONTagByOffset(unsafe.Offsetof(Config{}.ClientRateQPS)) | |
| - if was := resolvedCfg.ClientRateQPS; was <= 0 { | |
| - fallback := defaultConfig.ClientRateQPS | |
| - resolvedCfg.ClientRateQPS = fallback | |
| - rawCfg.ClientRateQPS = fallback | |
| - log.Warn(tagClientRateQPS+" clamped (must be greater than 0)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - tagClientBurstQPS := getJSONTagByOffset(unsafe.Offsetof(Config{}.ClientBurstQPS)) | |
| - // Constraint A: Absolute lower bound check | |
| - if was := resolvedCfg.ClientBurstQPS; was <= 0 { | |
| - fallback := defaultConfig.ClientBurstQPS | |
| - resolvedCfg.ClientBurstQPS = fallback | |
| - rawCfg.ClientBurstQPS = fallback | |
| - log.Warn(tagClientBurstQPS+" clamped (must be greater than 0)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } //else if was < newCfg.ClientRateQPS { | |
| - // Constraint B: Relational check (Executed sequentially, NEVER as an 'else if') | |
| - if was := resolvedCfg.ClientBurstQPS; was < resolvedCfg.ClientRateQPS { | |
| - fallback := resolvedCfg.ClientRateQPS | |
| - resolvedCfg.ClientBurstQPS = fallback | |
| - rawCfg.ClientBurstQPS = fallback | |
| - log.Warn(tagClientBurstQPS+" clamped (cannot be less than "+tagClientRateQPS+")", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - tagCacheMinTTL := getJSONTagByOffset(unsafe.Offsetof(Config{}.CacheMinTTL)) | |
| - if was := resolvedCfg.CacheMinTTL; was < cacheMinTTLClamp { | |
| - resolvedCfg.CacheMinTTL = cacheMinTTLClamp | |
| - rawCfg.CacheMinTTL = cacheMinTTLClamp | |
| - log.Warn(tagCacheMinTTL+" clamped to safe minimum", slog.Int("was", was), slog.Int("clamp", cacheMinTTLClamp)) | |
| - } | |
| - | |
| - tagCacheMaxEntries := getJSONTagByOffset(unsafe.Offsetof(Config{}.CacheMaxEntries)) | |
| - if was := resolvedCfg.CacheMaxEntries; was <= 0 { | |
| - fallback := defaultConfig.CacheMaxEntries | |
| - resolvedCfg.CacheMaxEntries = fallback | |
| - rawCfg.CacheMaxEntries = fallback | |
| - log.Warn(tagCacheMaxEntries+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - tagCacheJanitorIntervalMinutes := getJSONTagByOffset(unsafe.Offsetof(Config{}.CacheJanitorIntervalMinutes)) | |
| - if was := resolvedCfg.CacheJanitorIntervalMinutes; was <= 0 { | |
| - fallback := defaultConfig.CacheJanitorIntervalMinutes | |
| - resolvedCfg.CacheJanitorIntervalMinutes = fallback | |
| - rawCfg.CacheJanitorIntervalMinutes = fallback | |
| - log.Warn(tagCacheJanitorIntervalMinutes+" clamped to safe minimum interval", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - tagCacheNegativeTTLSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.CacheNegativeTTLSec)) | |
| - if was := resolvedCfg.CacheNegativeTTLSec; was < 0 { | |
| - fallback := defaultConfig.CacheNegativeTTLSec | |
| - resolvedCfg.CacheNegativeTTLSec = fallback | |
| - rawCfg.CacheNegativeTTLSec = fallback | |
| - log.Warn(tagCacheNegativeTTLSec+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - tagBlockedResponseTTLSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.BlockedResponseTTLSec)) | |
| - if was := resolvedCfg.BlockedResponseTTLSec; was < 0 { | |
| - fallback := defaultConfig.BlockedResponseTTLSec | |
| - resolvedCfg.BlockedResponseTTLSec = fallback | |
| - rawCfg.BlockedResponseTTLSec = fallback | |
| - log.Warn(tagBlockedResponseTTLSec+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - tagLocalHostsOverrideTTLSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.LocalHostsOverrideTTLSec)) | |
| - if was := resolvedCfg.LocalHostsOverrideTTLSec; was == 0 { | |
| - fallback := defaultConfig.LocalHostsOverrideTTLSec | |
| - resolvedCfg.LocalHostsOverrideTTLSec = fallback | |
| - rawCfg.LocalHostsOverrideTTLSec = fallback | |
| - log.Warn(tagLocalHostsOverrideTTLSec+" clamped", slog.Uint64("was", uint64(was)), slog.Uint64("clamp", uint64(fallback))) | |
| - } | |
| - | |
| - tagMaxRecentBlocks := getJSONTagByOffset(unsafe.Offsetof(Config{}.MaxRecentBlocks)) | |
| - if was := resolvedCfg.MaxRecentBlocks; was <= 0 { | |
| - fallback := defaultConfig.MaxRecentBlocks | |
| - resolvedCfg.MaxRecentBlocks = fallback | |
| - rawCfg.MaxRecentBlocks = fallback | |
| - log.Warn(tagMaxRecentBlocks+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - tagUILogMaxLines := getJSONTagByOffset(unsafe.Offsetof(Config{}.UILogMaxLines)) | |
| - if was := resolvedCfg.UILogMaxLines; was <= 0 { | |
| - fallback := defaultConfig.UILogMaxLines | |
| - resolvedCfg.UILogMaxLines = fallback | |
| - rawCfg.UILogMaxLines = fallback | |
| - log.Warn(tagUILogMaxLines+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - tagLogMaxSizeMB := getJSONTagByOffset(unsafe.Offsetof(Config{}.LogMaxSizeMB)) | |
| - if was := resolvedCfg.LogMaxSizeMB; was <= 0 { | |
| - fallback := defaultConfig.LogMaxSizeMB | |
| - resolvedCfg.LogMaxSizeMB = fallback | |
| - rawCfg.LogMaxSizeMB = fallback | |
| - log.Warn(tagLogMaxSizeMB+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - | |
| - // ========================================================================= | |
| - // IP Strings Parsing & Post-Processing Operations | |
| - // ========================================================================= | |
| - // Clean up and pre-parse IPv4 | |
| - //TODO: do I have to set the parseds to rawCfg too?! | |
| - if ip := net.ParseIP(resolvedCfg.BlockIP); ip != nil && ip.To4() != nil { | |
| - resolvedCfg.BlockIPv4Parsed = ip.To4() | |
| - rawCfg.BlockIPv4Parsed = ip.To4() | |
| - } else { | |
| - const IP0 = "0.0.0.0" | |
| - const zero = 0 | |
| - resolvedCfg.BlockIP = IP0 | |
| - rawCfg.BlockIP = IP0 | |
| - resolvedCfg.BlockIPv4Parsed = net.IPv4(zero, zero, zero, zero).To4() | |
| - rawCfg.BlockIPv4Parsed = resolvedCfg.BlockIPv4Parsed // TODO: hopefully no need to have diff. instances of this here? | |
| - } | |
| - | |
| - // Clean up and pre-parse IPv6 | |
| - if ip := net.ParseIP(resolvedCfg.BlockIPv6); ip != nil && ip.To16() != nil { | |
| - resolvedCfg.BlockIPv6Parsed = ip.To16() | |
| - rawCfg.BlockIPv6Parsed = ip.To16() // TODO: do we need this to be same instance for equals to say equals anywhere? | |
| - } else { | |
| - const IPv6Zero = "::" | |
| - resolvedCfg.BlockIPv6 = IPv6Zero | |
| - resolvedCfg.BlockIPv6Parsed = net.ParseIP(IPv6Zero).To16() | |
| - rawCfg.BlockIPv6Parsed = resolvedCfg.BlockIPv6Parsed // TODO: hopefully no need to have diff. instances of this here? | |
| - } | |
| - | |
| - // Validate ListenDoH host is a literal IP (required for TLS cert SAN) | |
| - tagListenDoH := getJSONTagByOffset(unsafe.Offsetof(Config{}.ListenDoH)) | |
| - if doHHost, _, splitErr := net.SplitHostPort(resolvedCfg.ListenDoH); splitErr != nil { | |
| - return fmt.Errorf("%q %q is not a valid host:port, actually must be IP:port, err: %w", tagListenDoH, resolvedCfg.ListenDoH, splitErr) | |
| - } else if net.ParseIP(doHHost) == nil { | |
| - return fmt.Errorf("%q host %q must be an IP literal with no surrounding spaces (not a hostname(because we can't look it up without DNS)) for TLS cert generation", tagListenDoH, doHHost) | |
| - } | |
| - if doHHost, _, splitErr := net.SplitHostPort(resolvedCfg.ListenUI); splitErr != nil { | |
| - return fmt.Errorf("%q %q is not a valid host:port, actually must be IP:port, err: %w", tagListenUI, resolvedCfg.ListenUI, splitErr) | |
| - } else if net.ParseIP(doHHost) == nil { | |
| - return fmt.Errorf("%q host %q must be an IP literal with no surrounding spaces (not a hostname(because we can't look it up without DNS)) for TLS cert generation", tagListenUI, doHHost) | |
| - } | |
| - | |
| - resolvedCfg.BlockMode = strings.ToLower(resolvedCfg.BlockMode) //XXX: lowercasing this for future comparisons to be easier! | |
| - rawCfg.BlockMode = resolvedCfg.BlockMode | |
| - //TODO: ensure only valid values are used here for config.BlockMode or warn/exit! | |
| - | |
| - //TODO: see if I've to shouldSaveConfig for anything else here, above maybe? | |
| - | |
| - if len(resolvedCfg.UpstreamSNIHostnames) > len(resolvedCfg.UpstreamURLs) { | |
| - const msg = "there are more SNIs vs URLs for upstream, only the opposite is allowed ( >= URLs than SNIs which then inherit the SNI from URLs)" | |
| - log.Warn(msg) | |
| - return fmt.Errorf("%s", msg) | |
| - } | |
| - // Ensure SNIHostnames has the same length as UpstreamURLs, falling back to the URL's hostname | |
| - for i := len(resolvedCfg.UpstreamSNIHostnames); i < len(resolvedCfg.UpstreamURLs); i++ { | |
| - host, err2 := hostFromURL(resolvedCfg.UpstreamURLs[i]) | |
| - if err2 != nil { | |
| - log.Warn("invalid upstream URL during SNI fill", slog.Int("index", i), SafeErr(err2)) | |
| - return fmt.Errorf("invalid upstream URL at index %d: %w", i, err2) | |
| + //Use the unified sanitization/validation helper --- | |
| + changed, errVal := sanitizeAndValidateConfig(log, resolvedCfg, rawCfg, &defaultConfig, false) | |
| + if errVal != nil { | |
| + // Intercept fatal strings and crash exactly as the old code did | |
| + if strings.HasPrefix(errVal.Error(), "FATAL:") { | |
| + s.logFatal2(strings.TrimPrefix(errVal.Error(), "FATAL: ")) | |
| } | |
| - rawCfg.UpstreamSNIHostnames = append(rawCfg.UpstreamSNIHostnames, host) | |
| - resolvedCfg.UpstreamSNIHostnames = append(resolvedCfg.UpstreamSNIHostnames, host) | |
| + return errVal | |
| + } | |
| + if changed { | |
| shouldSaveConfig = true | |
| } | |
| - //FIXME: this is weird, what are we doing here below vs above?! | |
| - for i := range resolvedCfg.UpstreamURLs { | |
| - if resolvedCfg.UpstreamSNIHostnames[i] != "" { | |
| - continue | |
| - } | |
| - host, err2 := hostFromURL(resolvedCfg.UpstreamURLs[i]) | |
| - if err2 != nil { | |
| - log.Error("invalid upstream URL", slog.Int("at_index", i), SafeErr(err2)) | |
| - return fmt.Errorf("invalid upstream URL at index %d: %w", i, err2) | |
| - } | |
| - rawCfg.UpstreamSNIHostnames[i] = host | |
| - resolvedCfg.UpstreamSNIHostnames[i] = host | |
| - shouldSaveConfig = true | |
| - } | |
| - log.Debug("Using upstream SNI hostnames:", | |
| - SafeStringSlice("SNI_hostnames", resolvedCfg.UpstreamSNIHostnames), | |
| - ) | |
| - | |
| - // Helper closure to apply the cleaning and track if a save is needed | |
| - checkAndClean := func(resolvedTarget, rawTarget *string, configKey, fallback string) { | |
| - if cleaned, changed := s.cleanFileName(*resolvedTarget, configKey, fallback); changed { | |
| - if *resolvedTarget != *rawTarget { | |
| - s.logFatal2(fmt.Sprintf("Won't overwrite template %q with cleaned value %q, you must do it manually then rerun.", *rawTarget, *resolvedTarget)) //FIXME: we should be able to do this? but this means either write into the file, or set the env.var. | |
| - } | |
| - *resolvedTarget = cleaned | |
| - *rawTarget = cleaned | |
| - if !shouldSaveConfig { | |
| - shouldSaveConfig = true | |
| - } | |
| - } | |
| - } | |
| - | |
| - checkAndClean(&resolvedCfg.BlacklistFile, &rawCfg.BlacklistFile, getJSONTagByOffset(unsafe.Offsetof(Config{}.BlacklistFile)), defaultConfig.BlacklistFile) | |
| - checkAndClean(&resolvedCfg.WhitelistFile, &rawCfg.WhitelistFile, getJSONTagByOffset(unsafe.Offsetof(Config{}.WhitelistFile)), defaultConfig.WhitelistFile) | |
| - checkAndClean(&resolvedCfg.LogQueriesFile, &rawCfg.LogQueriesFile, getJSONTagByOffset(unsafe.Offsetof(Config{}.LogQueriesFile)), defaultConfig.LogQueriesFile) | |
| - checkAndClean(&resolvedCfg.LogEverythingFile, &rawCfg.LogEverythingFile, getJSONTagByOffset(unsafe.Offsetof(Config{}.LogEverythingFile)), defaultConfig.LogEverythingFile) | |
| - checkAndClean(&resolvedCfg.HostsFile, &rawCfg.HostsFile, getJSONTagByOffset(unsafe.Offsetof(Config{}.HostsFile)), defaultConfig.HostsFile) | |
| - | |
| - // NEW: Enforce password setup if it's missing from the config | |
| + // Enforce password setup if it's missing from the config | |
| if resolvedCfg.WebUIPasswordHash == "" { | |
| log.Warn("No WebUI password configured. Securing WebUI now...") | |
| fmt.Println("\n========================================================") | |
| @@ -3116,54 +2656,6 @@ func (s *Server) loadConfig() error { | |
| return nil | |
| } | |
| -// cleanFileName returns the cleaned filename and a boolean indicating if the original was modified. | |
| -func (s *Server) cleanFileName(original, configKey, fallback string) (string, bool) { | |
| - log := s.getLogger() | |
| - | |
| - if original == "" { | |
| - if fallback == "" { | |
| - msg := fmt.Sprintf("BUG: dev fail: passed empty filename to clean for config key %q and the fallback was also empty!", configKey) | |
| - log.Error(msg, slog.String("config_key", configKey)) | |
| - panic(msg) | |
| - } | |
| - log.Warn("Bad filename in config, used fallback", | |
| - slog.String("for_config_key", configKey), | |
| - slog.String("bad_filename", original), | |
| - slog.String("fallback_filename", fallback)) | |
| - | |
| - // Ensure the fallback itself is clean before returning | |
| - cleaned := filepath.Clean(fallback) //FIXME: not a fan of having to call Clean twice, for DRY purposes. | |
| - if cleaned != fallback { | |
| - msg := fmt.Sprintf("BUG: dev fail: fallback(%q) for config key %q had to be cleaned into %q", fallback, configKey, cleaned) | |
| - log.Error(msg, slog.String("config_key", configKey), slog.String("fallback_filename", fallback), slog.String("filename_cleaned", cleaned)) | |
| - panic(msg) | |
| - } | |
| - return cleaned, true | |
| - } | |
| - | |
| - cleaned := filepath.Clean(original) | |
| - // Reject Windows reserved device names (CON, NUL, COM1, etc.). | |
| - // filepath.Base handles any directory prefix; TrimRight strips trailing | |
| - // dots and spaces that Windows itself strips before resolving the name. | |
| - baseName := strings.ToUpper(strings.TrimRight(filepath.Base(cleaned), ". ")) | |
| - if _, reserved := reservedNames[baseName]; reserved { | |
| - log.Warn("Config filename is a reserved Windows device name; using fallback", | |
| - slog.String("for_config_key", configKey), | |
| - slog.String("reserved_filename", cleaned), | |
| - slog.String("fallback_filename", fallback)) | |
| - return filepath.Clean(fallback), true | |
| - } | |
| - if cleaned != original { | |
| - log.Debug("Cleaned filename from config file", | |
| - slog.String("config_key", configKey), | |
| - slog.String("filename_before", original), | |
| - slog.String("filename_after", cleaned)) | |
| - return cleaned, true | |
| - } | |
| - | |
| - return original, false | |
| -} | |
| - | |
| // helper to return host (IP or hostname) from an URL | |
| func hostFromURL(raw string) (string, error) { | |
| u, err := url.Parse(raw) | |
| @@ -9588,28 +9080,27 @@ func (ui *AdminUI) configHandler(w http.ResponseWriter, r *http.Request) { | |
| } | |
| // Resolve tags in the dry-run so hard-checks don't crash on the literal {file:...} string | |
| resolved, err5 := resolveConfigTags(&testCfg) | |
| - //if _, err := resolveConfigTags(&testCfg); err != nil { | |
| if err5 != nil { | |
| http.Error(w, "Validation failed (tag resolution): "+err5.Error(), http.StatusBadRequest) | |
| return | |
| } | |
| - if len(resolved.UpstreamSNIHostnames) > len(resolved.UpstreamURLs) { | |
| - http.Error(w, "there are more SNIs vs URLs for upstream, only the opposite is allowed ( >= URLs than SNIs which then inherit the SNI from URLs)", http.StatusBadRequest) | |
| + // --- RUN UNIFIED SANITIZE AND VALIDATE --- | |
| + // Ensure it receives identical clamping, normalization, and bounds checking. | |
| + defCfg := defaultConfig() | |
| + _, errValid := sanitizeAndValidateConfig(log, resolved, &rawCfg, &defCfg, true) | |
| + if errValid != nil { | |
| + http.Error(w, "Validation failed: "+errValid.Error(), http.StatusBadRequest) | |
| return | |
| } | |
| - //TODO: make the SNIs inherit from URLs, if missing, like loadMainConfig() already does, but DRY the common code! | |
| - // Hard-check URLs to prevent loadMainConfig panics | |
| - for i, rawURL := range resolved.UpstreamURLs { | |
| - if _, err8 := url.Parse(rawURL); err8 != nil { | |
| - http.Error(w, fmt.Sprintf("Invalid upstream URL at index %d: %v", i, err8), http.StatusBadRequest) | |
| - return | |
| - } | |
| - if _, err9 := hostFromURL(rawURL); err9 != nil { | |
| - http.Error(w, fmt.Sprintf("Invalid upstream host at index %d: %v", i, err9), http.StatusBadRequest) | |
| - return | |
| - } | |
| + // Since sanitizeAndValidateConfig actively clamps unsafe values inside rawCfg | |
| + // (e.g. SNIs paddings or limits corrections), we MUST re-marshal it! | |
| + newData, err12 = json.MarshalIndent(rawCfg, "", " ") | |
| + if err12 != nil { | |
| + log.Error("Failed to re-marshal sanitized config", SafeErr(err12)) | |
| + http.Error(w, "failed to re-marshal config after sanitization", http.StatusInternalServerError) | |
| + return | |
| } | |
| // Commit to disk and trigger hot-reload | |
| @@ -9997,3 +9488,559 @@ func (ui *AdminUI) getRawConfig() *Config { | |
| panic2("BUG: AdminUI.liveRawConfig isn't inited, should point to the Server.liveRawConfig") | |
| panic(nil) | |
| } | |
| + | |
| +// sanitizeAndValidateConfig handles validation, clamping, and cleaning of configuration fields. | |
| +// It is used by both loadMainConfig (on disk load) and configHandler (on WebUI apply) to ensure | |
| +// identical constraint enforcement and normalization. | |
| +func sanitizeAndValidateConfig(log *slog.Logger, resolvedCfg, rawCfg, defaultCfg *Config, isWebUI bool) (bool, error) { | |
| + var shouldSaveConfig bool | |
| + | |
| + tagWebUIUseTLS := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUIUseTLS)) | |
| + tagWebUIForceTLS := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUIForceTLSOnNonLocalhost)) | |
| + tagListenUI := getJSONTagByOffset(unsafe.Offsetof(Config{}.ListenUI)) | |
| + | |
| + boundToLoopback := isLoopbackBindHost(resolvedCfg.ListenUI) | |
| + | |
| + switch { | |
| + case !resolvedCfg.WebUIUseTLS && !boundToLoopback && resolvedCfg.WebUIForceTLSOnNonLocalhost: | |
| + log.Warn(tagWebUIUseTLS+" was false while "+tagListenUI+" is bound off-loopback; "+ | |
| + "auto-promoting to TLS so the bcrypt-checked WebUI password isn't sent as plaintext(thus sniffable) "+ | |
| + "Basic-Auth over the network. Set "+tagWebUIForceTLS+" to false to override.", | |
| + slog.String("listen_ui", resolvedCfg.ListenUI)) | |
| + resolvedCfg.WebUIUseTLS = true | |
| + rawCfg.WebUIUseTLS = true | |
| + shouldSaveConfig = true //hmm, self-heals?! | |
| + | |
| + case !resolvedCfg.WebUIUseTLS && !boundToLoopback: | |
| + log.Error(tagWebUIUseTLS+" and "+tagWebUIForceTLS+" are both false while bound off-loopback; "+ | |
| + "the WebUI password will be sent in PLAINTEXT (Basic-Auth is base64, not encryption) "+ | |
| + "to anyone who can observe this network segment.", | |
| + slog.String("listen_ui", resolvedCfg.ListenUI)) | |
| + | |
| + case !resolvedCfg.WebUIUseTLS && boundToLoopback: | |
| + log.Warn(tagWebUIUseTLS+" is false. Even on loopback, Basic-Auth sends the password as base64 "+ | |
| + "(not encrypted) to any other local process/user that can observe loopback traffic.", | |
| + slog.String("listen_ui", resolvedCfg.ListenUI)) | |
| + } | |
| + | |
| + // ========================================================================= | |
| + // Group 1: WebUI Server Timeouts & Rate Limits | |
| + // ========================================================================= | |
| + tagWebUIReadHeader := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUIReadHeaderTimeoutSec)) | |
| + if was := resolvedCfg.WebUIReadHeaderTimeoutSec; was <= 0 { | |
| + fallback := defaultCfg.WebUIReadHeaderTimeoutSec | |
| + resolvedCfg.WebUIReadHeaderTimeoutSec = fallback | |
| + rawCfg.WebUIReadHeaderTimeoutSec = fallback | |
| + log.Warn(tagWebUIReadHeader+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagWebUIRead := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUIReadTimeoutSec)) | |
| + if was := resolvedCfg.WebUIReadTimeoutSec; was <= 0 { | |
| + fallback := defaultCfg.WebUIReadTimeoutSec | |
| + resolvedCfg.WebUIReadTimeoutSec = fallback | |
| + rawCfg.WebUIReadTimeoutSec = fallback | |
| + log.Warn(tagWebUIRead+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagWebUIWrite := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUIWriteTimeoutSec)) | |
| + if was := resolvedCfg.WebUIWriteTimeoutSec; was <= 0 { | |
| + fallback := defaultCfg.WebUIWriteTimeoutSec | |
| + resolvedCfg.WebUIWriteTimeoutSec = fallback | |
| + rawCfg.WebUIWriteTimeoutSec = fallback | |
| + log.Warn(tagWebUIWrite+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagWebUIIdle := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUIIdleTimeoutSec)) | |
| + if was := resolvedCfg.WebUIIdleTimeoutSec; was <= resolvedCfg.WebUIReadTimeoutSec { | |
| + fallback := resolvedCfg.WebUIReadTimeoutSec * 2 | |
| + resolvedCfg.WebUIIdleTimeoutSec = fallback | |
| + rawCfg.WebUIIdleTimeoutSec = fallback | |
| + log.Warn(tagWebUIIdle+" clamped(to double the read timeout) to prevent aggressive keep-alive disconnects", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagWebUIMaxLoginFailures := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUIMaxLoginFailures)) | |
| + if was := resolvedCfg.WebUIMaxLoginFailures; was <= 0 { | |
| + fallback := defaultCfg.WebUIMaxLoginFailures | |
| + resolvedCfg.WebUIMaxLoginFailures = fallback | |
| + rawCfg.WebUIMaxLoginFailures = fallback | |
| + log.Warn(tagWebUIMaxLoginFailures+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagWebUILoginLockoutSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUILoginLockoutSec)) | |
| + if was := resolvedCfg.WebUILoginLockoutSec; was <= 0 { | |
| + fallback := defaultCfg.WebUILoginLockoutSec | |
| + resolvedCfg.WebUILoginLockoutSec = fallback | |
| + rawCfg.WebUILoginLockoutSec = fallback | |
| + log.Warn(tagWebUILoginLockoutSec+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + // ========================================================================= | |
| + // Group 2: Local DoH Server Timeouts | |
| + // ========================================================================= | |
| + tagDoHHeader := getJSONTagByOffset(unsafe.Offsetof(Config{}.LocalDoHReadHeaderTimeoutSec)) | |
| + if was := resolvedCfg.LocalDoHReadHeaderTimeoutSec; was <= 0 { | |
| + fallback := defaultCfg.LocalDoHReadHeaderTimeoutSec | |
| + resolvedCfg.LocalDoHReadHeaderTimeoutSec = fallback | |
| + rawCfg.LocalDoHReadHeaderTimeoutSec = fallback | |
| + log.Warn(tagDoHHeader+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagDoHRead := getJSONTagByOffset(unsafe.Offsetof(Config{}.LocalDoHReadTimeoutSec)) | |
| + if was := resolvedCfg.LocalDoHReadTimeoutSec; was <= 0 { | |
| + fallback := defaultCfg.LocalDoHReadTimeoutSec | |
| + resolvedCfg.LocalDoHReadTimeoutSec = fallback | |
| + rawCfg.LocalDoHReadTimeoutSec = fallback | |
| + log.Warn(tagDoHRead+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagDoHWrite := getJSONTagByOffset(unsafe.Offsetof(Config{}.LocalDoHWriteTimeoutSec)) | |
| + if was := resolvedCfg.LocalDoHWriteTimeoutSec; was <= 0 { | |
| + fallback := defaultCfg.LocalDoHWriteTimeoutSec | |
| + resolvedCfg.LocalDoHWriteTimeoutSec = fallback | |
| + rawCfg.LocalDoHWriteTimeoutSec = fallback | |
| + log.Warn(tagDoHWrite+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagDoHIdle := getJSONTagByOffset(unsafe.Offsetof(Config{}.LocalDoHIdleTimeoutSec)) | |
| + if was := resolvedCfg.LocalDoHIdleTimeoutSec; was <= resolvedCfg.LocalDoHReadTimeoutSec { | |
| + fallback := resolvedCfg.LocalDoHReadTimeoutSec * 2 | |
| + resolvedCfg.LocalDoHIdleTimeoutSec = fallback | |
| + rawCfg.LocalDoHIdleTimeoutSec = fallback | |
| + log.Warn(tagDoHIdle+" clamped(to double the read timeout) to prevent premature keep-alive drops", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + // ========================================================================= | |
| + // Group 3: Upstream Client & Connection Pools | |
| + // ========================================================================= | |
| + tagUpstreamDialTimeoutSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.UpstreamDialTimeoutSec)) | |
| + if was := resolvedCfg.UpstreamDialTimeoutSec; was <= 0 { | |
| + fallback := defaultCfg.UpstreamDialTimeoutSec | |
| + resolvedCfg.UpstreamDialTimeoutSec = fallback | |
| + rawCfg.UpstreamDialTimeoutSec = fallback | |
| + log.Warn(tagUpstreamDialTimeoutSec+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagUpstreamClientTimeoutSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.UpstreamClientTimeoutSec)) | |
| + // Constraint A: Absolute lower bound check | |
| + if was := resolvedCfg.UpstreamClientTimeoutSec; was <= 0 { | |
| + fallback := defaultCfg.UpstreamClientTimeoutSec | |
| + resolvedCfg.UpstreamClientTimeoutSec = fallback | |
| + rawCfg.UpstreamClientTimeoutSec = fallback | |
| + log.Warn(tagUpstreamClientTimeoutSec+" clamped (prevents infinite hanging client connections)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + // Constraint B: Relational validation check (Sequential, never an 'else if') | |
| + if was := resolvedCfg.UpstreamClientTimeoutSec; was < resolvedCfg.UpstreamDialTimeoutSec { | |
| + fallback := resolvedCfg.UpstreamDialTimeoutSec | |
| + resolvedCfg.UpstreamClientTimeoutSec = fallback | |
| + rawCfg.UpstreamClientTimeoutSec = fallback | |
| + log.Warn(tagUpstreamClientTimeoutSec+" clamped (cannot be less than dial timeout "+tagUpstreamDialTimeoutSec+")", | |
| + slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagCertLogTimeoutSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.CertLogTimeoutSec)) | |
| + if was := resolvedCfg.CertLogTimeoutSec; was <= 0 { | |
| + fallback := defaultCfg.CertLogTimeoutSec | |
| + resolvedCfg.CertLogTimeoutSec = fallback | |
| + rawCfg.CertLogTimeoutSec = fallback | |
| + log.Warn(tagCertLogTimeoutSec+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagUpstreamRetryBackoffMs := getJSONTagByOffset(unsafe.Offsetof(Config{}.UpstreamRetryBackoffMs)) | |
| + if was := resolvedCfg.UpstreamRetryBackoffMs; was <= 0 { | |
| + fallback := defaultCfg.UpstreamRetryBackoffMs | |
| + resolvedCfg.UpstreamRetryBackoffMs = fallback | |
| + rawCfg.UpstreamRetryBackoffMs = fallback | |
| + log.Warn(tagUpstreamRetryBackoffMs+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagUpstreamRetriesPerQuery := getJSONTagByOffset(unsafe.Offsetof(Config{}.UpstreamRetriesPerQuery)) | |
| + if was := resolvedCfg.UpstreamRetriesPerQuery; was < 0 { | |
| + fallback := defaultCfg.UpstreamRetriesPerQuery | |
| + resolvedCfg.UpstreamRetriesPerQuery = fallback | |
| + rawCfg.UpstreamRetriesPerQuery = fallback | |
| + log.Warn(tagUpstreamRetriesPerQuery+" clamped (cannot be negative)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagUpstreamIdleConnTimeout := getJSONTagByOffset(unsafe.Offsetof(Config{}.UpstreamIdleConnTimeoutSec)) | |
| + if was := resolvedCfg.UpstreamIdleConnTimeoutSec; was <= 0 { | |
| + fallback := defaultCfg.UpstreamIdleConnTimeoutSec | |
| + resolvedCfg.UpstreamIdleConnTimeoutSec = fallback | |
| + rawCfg.UpstreamIdleConnTimeoutSec = fallback | |
| + log.Warn(tagUpstreamIdleConnTimeout+" clamped (connections stay open indefinitely or drop unpredictably)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagUpstreamMaxIdleConns := getJSONTagByOffset(unsafe.Offsetof(Config{}.UpstreamMaxIdleConns)) | |
| + if was := resolvedCfg.UpstreamMaxIdleConns; was <= 0 { | |
| + fallback := defaultCfg.UpstreamMaxIdleConns | |
| + resolvedCfg.UpstreamMaxIdleConns = fallback | |
| + rawCfg.UpstreamMaxIdleConns = fallback | |
| + log.Warn(tagUpstreamMaxIdleConns+" clamped (disables global keep-alive reuse)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagUpstreamMaxIdleConnsPerHost := getJSONTagByOffset(unsafe.Offsetof(Config{}.UpstreamMaxIdleConnsPerHost)) | |
| + // Constraint A: Absolute lower bound check | |
| + if was := resolvedCfg.UpstreamMaxIdleConnsPerHost; was <= 0 { | |
| + fallback := defaultCfg.UpstreamMaxIdleConnsPerHost | |
| + resolvedCfg.UpstreamMaxIdleConnsPerHost = fallback | |
| + rawCfg.UpstreamMaxIdleConnsPerHost = fallback | |
| + log.Warn(tagUpstreamMaxIdleConnsPerHost+" clamped (Go default of 2 severely throttles throughput)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + // Constraint B: Relational validation check (Sequential, never an 'else if') | |
| + if was := resolvedCfg.UpstreamMaxIdleConnsPerHost; was > resolvedCfg.UpstreamMaxIdleConns { | |
| + // Defensive check: Per-host pool limit can't realistically exceed global pool limit | |
| + fallback := resolvedCfg.UpstreamMaxIdleConns | |
| + resolvedCfg.UpstreamMaxIdleConnsPerHost = fallback | |
| + rawCfg.UpstreamMaxIdleConnsPerHost = fallback | |
| + log.Warn(tagUpstreamMaxIdleConnsPerHost+" clamped (cannot exceed "+tagUpstreamMaxIdleConns+")", | |
| + slog.Int("was", was), | |
| + slog.Int("clamp", fallback), | |
| + slog.Int(tagUpstreamMaxIdleConns, resolvedCfg.UpstreamMaxIdleConns), | |
| + ) | |
| + } | |
| + | |
| + // ========================================================================= | |
| + // Group 4: Local Client & Server Buffer Safeguards | |
| + // ========================================================================= | |
| + tagMaxConcurrentDNSTCPConns := getJSONTagByOffset(unsafe.Offsetof(Config{}.MaxConcurrentDNSTCPConns)) | |
| + if was := resolvedCfg.MaxConcurrentDNSTCPConns; was <= 0 { | |
| + fallback := defaultCfg.MaxConcurrentDNSTCPConns | |
| + resolvedCfg.MaxConcurrentDNSTCPConns = fallback | |
| + rawCfg.MaxConcurrentDNSTCPConns = fallback | |
| + log.Warn(tagMaxConcurrentDNSTCPConns+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagMaxConcurrentDNSUDPQueries := getJSONTagByOffset(unsafe.Offsetof(Config{}.MaxConcurrentDNSUDPQueries)) | |
| + if was := resolvedCfg.MaxConcurrentDNSUDPQueries; was <= 0 { | |
| + fallback := defaultCfg.MaxConcurrentDNSUDPQueries | |
| + resolvedCfg.MaxConcurrentDNSUDPQueries = fallback | |
| + rawCfg.MaxConcurrentDNSUDPQueries = fallback | |
| + log.Warn(tagMaxConcurrentDNSUDPQueries+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagClientTCPTimeoutSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.ClientTCPTimeoutSec)) | |
| + if was := resolvedCfg.ClientTCPTimeoutSec; was <= 0 { | |
| + fallback := defaultCfg.ClientTCPTimeoutSec | |
| + resolvedCfg.ClientTCPTimeoutSec = fallback | |
| + rawCfg.ClientTCPTimeoutSec = fallback | |
| + log.Warn(tagClientTCPTimeoutSec+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagDoHMaxRequestBodyBytes := getJSONTagByOffset(unsafe.Offsetof(Config{}.DoHMaxRequestBodyBytes)) | |
| + if was := resolvedCfg.DoHMaxRequestBodyBytes; was <= 0 { | |
| + fallback := defaultCfg.DoHMaxRequestBodyBytes | |
| + resolvedCfg.DoHMaxRequestBodyBytes = fallback | |
| + rawCfg.DoHMaxRequestBodyBytes = fallback | |
| + log.Warn(tagDoHMaxRequestBodyBytes+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagDNSUDPBufferSize := getJSONTagByOffset(unsafe.Offsetof(Config{}.DNSUDPBufferSize)) | |
| + if was := resolvedCfg.DNSUDPBufferSize; was < 512 || was > 65535 { | |
| + fallback := defaultCfg.DNSUDPBufferSize | |
| + resolvedCfg.DNSUDPBufferSize = fallback | |
| + rawCfg.DNSUDPBufferSize = fallback | |
| + log.Warn(tagDNSUDPBufferSize+" clamped (must be within standard Ethernet bounds 512-65535)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + // ========================================================================= | |
| + // Group 5: Core Engine Limits & Cache Operations | |
| + // ========================================================================= | |
| + tagGlobalRateQPS := getJSONTagByOffset(unsafe.Offsetof(Config{}.GlobalRateQPS)) | |
| + if was := resolvedCfg.GlobalRateQPS; was <= 0 { | |
| + fallback := defaultCfg.GlobalRateQPS | |
| + resolvedCfg.GlobalRateQPS = fallback | |
| + rawCfg.GlobalRateQPS = fallback | |
| + log.Warn(tagGlobalRateQPS+" clamped (must be greater than 0)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagGlobalBurstQPS := getJSONTagByOffset(unsafe.Offsetof(Config{}.GlobalBurstQPS)) | |
| + // Constraint A: Absolute lower bound check | |
| + if was := resolvedCfg.GlobalBurstQPS; was <= 0 { | |
| + fallback := defaultCfg.GlobalBurstQPS | |
| + resolvedCfg.GlobalBurstQPS = fallback | |
| + rawCfg.GlobalBurstQPS = fallback | |
| + log.Warn(tagGlobalBurstQPS+" clamped (must be greater than 0)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + // Constraint B: Relational check (Executed sequentially, NEVER as an 'else if') | |
| + if was := resolvedCfg.GlobalBurstQPS; was < resolvedCfg.GlobalRateQPS { | |
| + fallback := resolvedCfg.GlobalRateQPS | |
| + resolvedCfg.GlobalBurstQPS = fallback | |
| + rawCfg.GlobalBurstQPS = fallback | |
| + log.Warn(tagGlobalBurstQPS+" clamped (cannot be less than "+tagGlobalRateQPS+")", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagClientRateQPS := getJSONTagByOffset(unsafe.Offsetof(Config{}.ClientRateQPS)) | |
| + if was := resolvedCfg.ClientRateQPS; was <= 0 { | |
| + fallback := defaultCfg.ClientRateQPS | |
| + resolvedCfg.ClientRateQPS = fallback | |
| + rawCfg.ClientRateQPS = fallback | |
| + log.Warn(tagClientRateQPS+" clamped (must be greater than 0)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagClientBurstQPS := getJSONTagByOffset(unsafe.Offsetof(Config{}.ClientBurstQPS)) | |
| + // Constraint A: Absolute lower bound check | |
| + if was := resolvedCfg.ClientBurstQPS; was <= 0 { | |
| + fallback := defaultCfg.ClientBurstQPS | |
| + resolvedCfg.ClientBurstQPS = fallback | |
| + rawCfg.ClientBurstQPS = fallback | |
| + log.Warn(tagClientBurstQPS+" clamped (must be greater than 0)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + // Constraint B: Relational check (Executed sequentially, NEVER as an 'else if') | |
| + if was := resolvedCfg.ClientBurstQPS; was < resolvedCfg.ClientRateQPS { | |
| + fallback := resolvedCfg.ClientRateQPS | |
| + resolvedCfg.ClientBurstQPS = fallback | |
| + rawCfg.ClientBurstQPS = fallback | |
| + log.Warn(tagClientBurstQPS+" clamped (cannot be less than "+tagClientRateQPS+")", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagCacheMinTTL := getJSONTagByOffset(unsafe.Offsetof(Config{}.CacheMinTTL)) | |
| + if was := resolvedCfg.CacheMinTTL; was < cacheMinTTLClamp { | |
| + resolvedCfg.CacheMinTTL = cacheMinTTLClamp | |
| + rawCfg.CacheMinTTL = cacheMinTTLClamp | |
| + log.Warn(tagCacheMinTTL+" clamped to safe minimum", slog.Int("was", was), slog.Int("clamp", cacheMinTTLClamp)) | |
| + } | |
| + | |
| + tagCacheMaxEntries := getJSONTagByOffset(unsafe.Offsetof(Config{}.CacheMaxEntries)) | |
| + if was := resolvedCfg.CacheMaxEntries; was <= 0 { | |
| + fallback := defaultCfg.CacheMaxEntries | |
| + resolvedCfg.CacheMaxEntries = fallback | |
| + rawCfg.CacheMaxEntries = fallback | |
| + log.Warn(tagCacheMaxEntries+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagCacheJanitorIntervalMinutes := getJSONTagByOffset(unsafe.Offsetof(Config{}.CacheJanitorIntervalMinutes)) | |
| + if was := resolvedCfg.CacheJanitorIntervalMinutes; was <= 0 { | |
| + fallback := defaultCfg.CacheJanitorIntervalMinutes | |
| + resolvedCfg.CacheJanitorIntervalMinutes = fallback | |
| + rawCfg.CacheJanitorIntervalMinutes = fallback | |
| + log.Warn(tagCacheJanitorIntervalMinutes+" clamped to safe minimum interval", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagCacheNegativeTTLSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.CacheNegativeTTLSec)) | |
| + if was := resolvedCfg.CacheNegativeTTLSec; was < 0 { | |
| + fallback := defaultCfg.CacheNegativeTTLSec | |
| + resolvedCfg.CacheNegativeTTLSec = fallback | |
| + rawCfg.CacheNegativeTTLSec = fallback | |
| + log.Warn(tagCacheNegativeTTLSec+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagBlockedResponseTTLSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.BlockedResponseTTLSec)) | |
| + if was := resolvedCfg.BlockedResponseTTLSec; was < 0 { | |
| + fallback := defaultCfg.BlockedResponseTTLSec | |
| + resolvedCfg.BlockedResponseTTLSec = fallback | |
| + rawCfg.BlockedResponseTTLSec = fallback | |
| + log.Warn(tagBlockedResponseTTLSec+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagLocalHostsOverrideTTLSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.LocalHostsOverrideTTLSec)) | |
| + if was := resolvedCfg.LocalHostsOverrideTTLSec; was == 0 { | |
| + fallback := defaultCfg.LocalHostsOverrideTTLSec | |
| + resolvedCfg.LocalHostsOverrideTTLSec = fallback | |
| + rawCfg.LocalHostsOverrideTTLSec = fallback | |
| + log.Warn(tagLocalHostsOverrideTTLSec+" clamped", slog.Uint64("was", uint64(was)), slog.Uint64("clamp", uint64(fallback))) | |
| + } | |
| + | |
| + tagMaxRecentBlocks := getJSONTagByOffset(unsafe.Offsetof(Config{}.MaxRecentBlocks)) | |
| + if was := resolvedCfg.MaxRecentBlocks; was <= 0 { | |
| + fallback := defaultCfg.MaxRecentBlocks | |
| + resolvedCfg.MaxRecentBlocks = fallback | |
| + rawCfg.MaxRecentBlocks = fallback | |
| + log.Warn(tagMaxRecentBlocks+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagUILogMaxLines := getJSONTagByOffset(unsafe.Offsetof(Config{}.UILogMaxLines)) | |
| + if was := resolvedCfg.UILogMaxLines; was <= 0 { | |
| + fallback := defaultCfg.UILogMaxLines | |
| + resolvedCfg.UILogMaxLines = fallback | |
| + rawCfg.UILogMaxLines = fallback | |
| + log.Warn(tagUILogMaxLines+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagLogMaxSizeMB := getJSONTagByOffset(unsafe.Offsetof(Config{}.LogMaxSizeMB)) | |
| + if was := resolvedCfg.LogMaxSizeMB; was <= 0 { | |
| + fallback := defaultCfg.LogMaxSizeMB | |
| + resolvedCfg.LogMaxSizeMB = fallback | |
| + rawCfg.LogMaxSizeMB = fallback | |
| + log.Warn(tagLogMaxSizeMB+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + // ========================================================================= | |
| + // IP Strings Parsing & Post-Processing Operations | |
| + // ========================================================================= | |
| + // Clean up and pre-parse IPv4 | |
| + //TODO: do I have to set the parseds to rawCfg too?! | |
| + if ip := net.ParseIP(resolvedCfg.BlockIP); ip != nil && ip.To4() != nil { | |
| + resolvedCfg.BlockIPv4Parsed = ip.To4() | |
| + rawCfg.BlockIPv4Parsed = ip.To4() | |
| + } else { | |
| + const IP0 = "0.0.0.0" | |
| + const zero = 0 | |
| + resolvedCfg.BlockIP = IP0 | |
| + rawCfg.BlockIP = IP0 | |
| + resolvedCfg.BlockIPv4Parsed = net.IPv4(zero, zero, zero, zero).To4() | |
| + rawCfg.BlockIPv4Parsed = resolvedCfg.BlockIPv4Parsed // TODO: hopefully no need to have diff. instances of this here? | |
| + } | |
| + | |
| + // Clean up and pre-parse IPv6 | |
| + if ip := net.ParseIP(resolvedCfg.BlockIPv6); ip != nil && ip.To16() != nil { | |
| + resolvedCfg.BlockIPv6Parsed = ip.To16() | |
| + rawCfg.BlockIPv6Parsed = ip.To16() // TODO: do we need this to be same instance for equals to say equals anywhere? | |
| + } else { | |
| + const IPv6Zero = "::" | |
| + resolvedCfg.BlockIPv6 = IPv6Zero | |
| + resolvedCfg.BlockIPv6Parsed = net.ParseIP(IPv6Zero).To16() | |
| + rawCfg.BlockIPv6Parsed = resolvedCfg.BlockIPv6Parsed // TODO: hopefully no need to have diff. instances of this here? | |
| + } | |
| + | |
| + // Validate ListenDoH host is a literal IP (required for TLS cert SAN) | |
| + tagListenDoH := getJSONTagByOffset(unsafe.Offsetof(Config{}.ListenDoH)) | |
| + if doHHost, _, splitErr := net.SplitHostPort(resolvedCfg.ListenDoH); splitErr != nil { | |
| + return shouldSaveConfig, fmt.Errorf("%q %q is not a valid host:port, actually must be IP:port, err: %w", tagListenDoH, resolvedCfg.ListenDoH, splitErr) | |
| + } else if net.ParseIP(doHHost) == nil { | |
| + return shouldSaveConfig, fmt.Errorf("%q host %q must be an IP literal with no surrounding spaces (not a hostname(because we can't look it up without DNS)) for TLS cert generation", tagListenDoH, doHHost) | |
| + } | |
| + //tagListenUI := getJSONTagByOffset(unsafe.Offsetof(Config{}.ListenUI)) // dup | |
| + if uiHost, _, splitErr := net.SplitHostPort(resolvedCfg.ListenUI); splitErr != nil { | |
| + return shouldSaveConfig, fmt.Errorf("%q %q is not a valid host:port, actually must be IP:port, err: %w", tagListenUI, resolvedCfg.ListenUI, splitErr) | |
| + } else if net.ParseIP(uiHost) == nil { | |
| + return shouldSaveConfig, fmt.Errorf("%q host %q must be an IP literal with no surrounding spaces (not a hostname(because we can't look it up without DNS)) for TLS cert generation", tagListenUI, uiHost) | |
| + } | |
| + | |
| + resolvedCfg.BlockMode = strings.ToLower(resolvedCfg.BlockMode) //XXX: lowercasing this for future comparisons to be easier! | |
| + rawCfg.BlockMode = resolvedCfg.BlockMode | |
| + //TODO: ensure only valid values are used here for config.BlockMode or warn/exit! | |
| + | |
| + //TODO: see if I've to shouldSaveConfig for anything else here, above maybe? | |
| + | |
| + // Hard-check URLs unconditionally to prevent downstream panics | |
| + for i, rawURL := range resolvedCfg.UpstreamURLs { | |
| + if _, err := url.Parse(rawURL); err != nil { | |
| + return shouldSaveConfig, fmt.Errorf("invalid upstream URL at index %d: %w", i, err) | |
| + } | |
| + if _, err := hostFromURL(rawURL); err != nil { | |
| + return shouldSaveConfig, fmt.Errorf("invalid upstream host at index %d: %w", i, err) | |
| + } | |
| + } | |
| + | |
| + if len(resolvedCfg.UpstreamSNIHostnames) > len(resolvedCfg.UpstreamURLs) { | |
| + const msg = "there are more SNIs vs URLs for upstream, only the opposite is allowed ( >= URLs than SNIs which then inherit the SNI from URLs)" | |
| + log.Warn(msg) | |
| + return shouldSaveConfig, fmt.Errorf("%s", msg) | |
| + } | |
| + // Ensure SNIHostnames has the same length as UpstreamURLs, falling back to the URL's hostname | |
| + for i := len(resolvedCfg.UpstreamSNIHostnames); i < len(resolvedCfg.UpstreamURLs); i++ { | |
| + host, err2 := hostFromURL(resolvedCfg.UpstreamURLs[i]) | |
| + if err2 != nil { | |
| + log.Warn("invalid upstream URL during SNI fill", slog.Int("index", i), SafeErr(err2)) | |
| + return shouldSaveConfig, fmt.Errorf("invalid upstream URL at index %d: %w", i, err2) | |
| + } | |
| + rawCfg.UpstreamSNIHostnames = append(rawCfg.UpstreamSNIHostnames, host) | |
| + resolvedCfg.UpstreamSNIHostnames = append(resolvedCfg.UpstreamSNIHostnames, host) | |
| + shouldSaveConfig = true | |
| + } | |
| + //FIXME: this is weird, what are we doing here below vs above?! | |
| + for i := range resolvedCfg.UpstreamURLs { | |
| + if resolvedCfg.UpstreamSNIHostnames[i] != "" { | |
| + continue | |
| + } | |
| + host, err2 := hostFromURL(resolvedCfg.UpstreamURLs[i]) | |
| + if err2 != nil { | |
| + log.Error("invalid upstream URL", slog.Int("at_index", i), SafeErr(err2)) | |
| + return shouldSaveConfig, fmt.Errorf("invalid upstream URL at index %d: %w", i, err2) | |
| + } | |
| + rawCfg.UpstreamSNIHostnames[i] = host | |
| + resolvedCfg.UpstreamSNIHostnames[i] = host | |
| + shouldSaveConfig = true | |
| + } | |
| + log.Debug("Using upstream SNI hostnames:", | |
| + SafeStringSlice("SNI_hostnames", resolvedCfg.UpstreamSNIHostnames), | |
| + ) | |
| + | |
| + // Helper closure to apply the cleaning and track if a save is needed | |
| + checkAndClean := func(resolvedTarget, rawTarget *string, configKey, fallback string) error { | |
| + if cleaned, changed := cleanFileName(log, *resolvedTarget, configKey, fallback); changed { | |
| + if *resolvedTarget != *rawTarget { | |
| + errStr := fmt.Sprintf("Won't overwrite template %q with cleaned value %q, you must do it manually then rerun.", *rawTarget, cleaned) | |
| + if isWebUI { | |
| + return errors.New(errStr) | |
| + } else { | |
| + return errors.New("FATAL: " + errStr) | |
| + } | |
| + } | |
| + *resolvedTarget = cleaned | |
| + *rawTarget = cleaned | |
| + if !shouldSaveConfig { | |
| + shouldSaveConfig = true | |
| + } | |
| + } | |
| + return nil | |
| + } | |
| + | |
| + if err := checkAndClean(&resolvedCfg.BlacklistFile, &rawCfg.BlacklistFile, getJSONTagByOffset(unsafe.Offsetof(Config{}.BlacklistFile)), defaultCfg.BlacklistFile); err != nil { | |
| + return shouldSaveConfig, err | |
| + } | |
| + if err := checkAndClean(&resolvedCfg.WhitelistFile, &rawCfg.WhitelistFile, getJSONTagByOffset(unsafe.Offsetof(Config{}.WhitelistFile)), defaultCfg.WhitelistFile); err != nil { | |
| + return shouldSaveConfig, err | |
| + } | |
| + if err := checkAndClean(&resolvedCfg.LogQueriesFile, &rawCfg.LogQueriesFile, getJSONTagByOffset(unsafe.Offsetof(Config{}.LogQueriesFile)), defaultCfg.LogQueriesFile); err != nil { | |
| + return shouldSaveConfig, err | |
| + } | |
| + if err := checkAndClean(&resolvedCfg.LogEverythingFile, &rawCfg.LogEverythingFile, getJSONTagByOffset(unsafe.Offsetof(Config{}.LogEverythingFile)), defaultCfg.LogEverythingFile); err != nil { | |
| + return shouldSaveConfig, err | |
| + } | |
| + if err := checkAndClean(&resolvedCfg.HostsFile, &rawCfg.HostsFile, getJSONTagByOffset(unsafe.Offsetof(Config{}.HostsFile)), defaultCfg.HostsFile); err != nil { | |
| + return shouldSaveConfig, err | |
| + } | |
| + | |
| + if resolvedCfg.WebUIPasswordHash == "" && isWebUI { | |
| + return shouldSaveConfig, errors.New("webui_password_hash cannot be empty") | |
| + } | |
| + | |
| + return shouldSaveConfig, nil | |
| +} | |
| + | |
| +// cleanFileName returns the cleaned filename and a boolean indicating if the original was modified. | |
| +// //extracted this method to be a free function so the standalone logic can use it independently | |
| +func cleanFileName(log *slog.Logger, original, configKey, fallback string) (string, bool) { | |
| + if original == "" { | |
| + if fallback == "" { | |
| + msg := fmt.Sprintf("BUG: dev fail: passed empty filename to clean for config key %q and the fallback was also empty!", configKey) | |
| + log.Error(msg, slog.String("config_key", configKey)) | |
| + panic(msg) | |
| + } | |
| + log.Warn("Bad filename in config, used fallback", | |
| + slog.String("for_config_key", configKey), | |
| + slog.String("bad_filename", original), | |
| + slog.String("fallback_filename", fallback)) | |
| + | |
| + // Ensure the fallback itself is clean before returning | |
| + cleaned := filepath.Clean(fallback) //FIXME: not a fan of having to call Clean twice, for DRY purposes. | |
| + if cleaned != fallback { | |
| + msg := fmt.Sprintf("BUG: dev fail: fallback(%q) for config key %q had to be cleaned into %q", fallback, configKey, cleaned) | |
| + log.Error(msg, slog.String("config_key", configKey), slog.String("fallback_filename", fallback), slog.String("filename_cleaned", cleaned)) | |
| + panic(msg) | |
| + } | |
| + return cleaned, true | |
| + } | |
| + | |
| + cleaned := filepath.Clean(original) | |
| + // Reject Windows reserved device names (CON, NUL, COM1, etc.). | |
| + // filepath.Base handles any directory prefix; TrimRight strips trailing | |
| + // dots and spaces that Windows itself strips before resolving the name. | |
| + baseName := strings.ToUpper(strings.TrimRight(filepath.Base(cleaned), ". ")) | |
| + if _, reserved := reservedNames[baseName]; reserved { | |
| + log.Warn("Config filename is a reserved Windows device name; using fallback", | |
| + slog.String("for_config_key", configKey), | |
| + slog.String("reserved_filename", cleaned), | |
| + slog.String("fallback_filename", fallback)) | |
| + return filepath.Clean(fallback), true | |
| + } | |
| + if cleaned != original { | |
| + log.Debug("Cleaned filename from config file", | |
| + slog.String("config_key", configKey), | |
| + slog.String("filename_before", original), | |
| + slog.String("filename_after", cleaned)) | |
| + return cleaned, true | |
| + } | |
| + | |
| + return original, false | |
| +} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| diff --git a/internal/dnsbollocks/platform_windows.go b/internal/dnsbollocks/platform_windows.go | |
| index c514ab5..d5c01d8 100644 | |
| --- a/internal/dnsbollocks/platform_windows.go | |
| +++ b/internal/dnsbollocks/platform_windows.go | |
| @@ -2570,2237 +2570,1729 @@ func (s *Server) loadMainConfig() error { | |
| s.fileWriter.SetExtraSafety(resolvedCfg.ExtraSafety) //uses newly loaded config settings ie. cfg.ExtraSafety | |
| - tagWebUIUseTLS := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUIUseTLS)) | |
| - tagWebUIForceTLS := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUIForceTLSOnNonLocalhost)) | |
| - tagListenUI := getJSONTagByOffset(unsafe.Offsetof(Config{}.ListenUI)) | |
| - | |
| - boundToLoopback := isLoopbackBindHost(resolvedCfg.ListenUI) | |
| + //Use the unified sanitization/validation helper --- | |
| + changed, errVal := sanitizeAndValidateConfig(log, resolvedCfg, rawCfg, &defaultConfig, false) | |
| + if errVal != nil { | |
| + // Intercept fatal strings and crash exactly as the old code did | |
| + if strings.HasPrefix(errVal.Error(), "FATAL:") { | |
| + s.logFatal2(strings.TrimPrefix(errVal.Error(), "FATAL: ")) | |
| + } | |
| + return errVal | |
| + } | |
| + if changed { | |
| + shouldSaveConfig = true | |
| + } | |
| - switch { | |
| - case !resolvedCfg.WebUIUseTLS && !boundToLoopback && resolvedCfg.WebUIForceTLSOnNonLocalhost: | |
| - log.Warn(tagWebUIUseTLS+" was false while "+tagListenUI+" is bound off-loopback; "+ | |
| - "auto-promoting to TLS so the bcrypt-checked WebUI password isn't sent as plaintext(thus sniffable) "+ | |
| - "Basic-Auth over the network. Set "+tagWebUIForceTLS+" to false to override.", | |
| - slog.String("listen_ui", resolvedCfg.ListenUI)) | |
| - resolvedCfg.WebUIUseTLS = true | |
| - rawCfg.WebUIUseTLS = true | |
| - shouldSaveConfig = true //hmm, self-heals?! | |
| + // Enforce password setup if it's missing from the config | |
| + if resolvedCfg.WebUIPasswordHash == "" { | |
| + log.Warn("No WebUI password configured. Securing WebUI now...") | |
| + fmt.Println("\n========================================================") | |
| + fmt.Println(" INITIAL SETUP: SECURING YOUR WEB CONTROL PANEL ") | |
| + fmt.Println("========================================================") | |
| + hash, err2 := promptAndHashPassword(log) | |
| + if err2 != nil { | |
| + s.logFatal2("Failed to setup password (aborted): " + err2.Error()) | |
| + panic2("BUG: unreachable") | |
| + } | |
| - case !resolvedCfg.WebUIUseTLS && !boundToLoopback: | |
| - log.Error(tagWebUIUseTLS+" and "+tagWebUIForceTLS+" are both false while bound off-loopback; "+ | |
| - "the WebUI password will be sent in PLAINTEXT (Basic-Auth is base64, not encryption) "+ | |
| - "to anyone who can observe this network segment.", | |
| - slog.String("listen_ui", resolvedCfg.ListenUI)) | |
| + // Update live config instance | |
| + resolvedCfg.WebUIPasswordHash = hash | |
| + rawCfg.WebUIPasswordHash = hash | |
| - case !resolvedCfg.WebUIUseTLS && boundToLoopback: | |
| - log.Warn(tagWebUIUseTLS+" is false. Even on loopback, Basic-Auth sends the password as base64 "+ | |
| - "(not encrypted) to any other local process/user that can observe loopback traffic.", | |
| - slog.String("listen_ui", resolvedCfg.ListenUI)) | |
| + log.Info("WebUI password successfully set.") | |
| + if !shouldSaveConfig { | |
| + shouldSaveConfig = true | |
| + } | |
| } | |
| - // ========================================================================= | |
| - // Group 1: WebUI Server Timeouts & Rate Limits (Refactor-safe) | |
| - // ========================================================================= | |
| - tagWebUIReadHeader := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUIReadHeaderTimeoutSec)) | |
| - if was := resolvedCfg.WebUIReadHeaderTimeoutSec; was <= 0 { | |
| - fallback := defaultConfig.WebUIReadHeaderTimeoutSec | |
| - resolvedCfg.WebUIReadHeaderTimeoutSec = fallback | |
| - rawCfg.WebUIReadHeaderTimeoutSec = fallback | |
| - log.Warn(tagWebUIReadHeader+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + // Apply the fully validated config atomically | |
| + // 2. APPLY THE VALIDATED CONFIG ATOMICALLY | |
| + // From this exact microsecond, all new DNS queries will use the clamped, safe config. | |
| + s.liveRawConfig.Store(rawCfg) | |
| + s.applyConfig(*resolvedCfg) | |
| + if shouldSaveConfig { | |
| + // saveConfig internally calls s.getConfig(), which now has the fully updated data | |
| + if err = s.saveConfig(); err != nil { | |
| + return fmt.Errorf("config save failed: %w", err) | |
| + } | |
| } | |
| - | |
| - tagWebUIRead := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUIReadTimeoutSec)) | |
| - if was := resolvedCfg.WebUIReadTimeoutSec; was <= 0 { | |
| - fallback := defaultConfig.WebUIReadTimeoutSec | |
| - resolvedCfg.WebUIReadTimeoutSec = fallback | |
| - rawCfg.WebUIReadTimeoutSec = fallback | |
| - log.Warn(tagWebUIRead+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + // 4. LOG STRATEGY | |
| + // Add your new clear architectural description line here: | |
| + switch resolvedCfg.UpstreamSelectionMode { | |
| + case "strict": | |
| + log.Info("Upstream DNS strategy initialized: STRICT MATCH MODE (All upstreams queried; queries will be safely dropped if response IPs mismatch to protect against manipulation/spoofing; WARNING: Virtually unusable on standard networks due to false-positive drops caused by modern CDNs, Geo-DNS routing, and load balancers returning different IPs for identical queries.).") | |
| + case "failover": | |
| + log.Info("Upstream DNS strategy initialized: FAILOVER MODE (Sticky sequence tracking; queries the current active upstream and all higher-priority(first in list are higher prio.) failed upstreams in parallel to eliminate timeout penalties while instantly healing and restoring primary upstreams the moment they recover.).") | |
| + case "fastest": | |
| + //nolint:gocritic // Reason: Keeping 'fastest' explicit for readability | |
| + fallthrough | |
| + default: | |
| + log.Info("Upstream DNS strategy initialized: FASTEST WINS MODE (Racing upstreams concurrently; the first successful response is accepted immediately to optimize for CDNs, Geo-DNS, and speed).") | |
| } | |
| + //so above was load config.json | |
| + return nil | |
| +} | |
| - tagWebUIWrite := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUIWriteTimeoutSec)) | |
| - if was := resolvedCfg.WebUIWriteTimeoutSec; was <= 0 { | |
| - fallback := defaultConfig.WebUIWriteTimeoutSec | |
| - resolvedCfg.WebUIWriteTimeoutSec = fallback | |
| - rawCfg.WebUIWriteTimeoutSec = fallback | |
| - log.Warn(tagWebUIWrite+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| +func (s *Server) loadConfig() error { | |
| + var err error = s.loadMainConfig() | |
| + if err != nil { | |
| + return err | |
| } | |
| - | |
| - tagWebUIIdle := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUIIdleTimeoutSec)) | |
| - if was := resolvedCfg.WebUIIdleTimeoutSec; was <= resolvedCfg.WebUIReadTimeoutSec { | |
| - fallback := resolvedCfg.WebUIReadTimeoutSec * 2 | |
| - resolvedCfg.WebUIIdleTimeoutSec = fallback | |
| - rawCfg.WebUIIdleTimeoutSec = fallback | |
| - log.Warn(tagWebUIIdle+" clamped(to double the read timeout) to prevent aggressive keep-alive disconnects", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + // After decoding and applying config, because these use it: | |
| + // 3. LOAD DEPENDENT FILES | |
| + // Now that s.getConfig() returns the NEW config, these will use the correct file paths. | |
| + err = s.loadQueryWhitelist() | |
| + if err != nil { | |
| + return err | |
| } | |
| - | |
| - tagWebUIMaxLoginFailures := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUIMaxLoginFailures)) | |
| - if was := resolvedCfg.WebUIMaxLoginFailures; was <= 0 { | |
| - fallback := defaultConfig.WebUIMaxLoginFailures | |
| - resolvedCfg.WebUIMaxLoginFailures = fallback | |
| - rawCfg.WebUIMaxLoginFailures = fallback | |
| - log.Warn(tagWebUIMaxLoginFailures+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + err = s.loadResponseBlacklist() | |
| + if err != nil { | |
| + return err | |
| } | |
| - | |
| - tagWebUILoginLockoutSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUILoginLockoutSec)) | |
| - if was := resolvedCfg.WebUILoginLockoutSec; was <= 0 { | |
| - fallback := defaultConfig.WebUILoginLockoutSec | |
| - resolvedCfg.WebUILoginLockoutSec = fallback | |
| - rawCfg.WebUILoginLockoutSec = fallback | |
| - log.Warn(tagWebUILoginLockoutSec+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + if err = s.loadLocalHosts(); err != nil { | |
| + return err | |
| } | |
| - // ========================================================================= | |
| - // Group 2: Local DoH Server Timeouts | |
| - // ========================================================================= | |
| - tagDoHHeader := getJSONTagByOffset(unsafe.Offsetof(Config{}.LocalDoHReadHeaderTimeoutSec)) | |
| - if was := resolvedCfg.LocalDoHReadHeaderTimeoutSec; was <= 0 { | |
| - fallback := defaultConfig.LocalDoHReadHeaderTimeoutSec | |
| - resolvedCfg.LocalDoHReadHeaderTimeoutSec = fallback | |
| - rawCfg.LocalDoHReadHeaderTimeoutSec = fallback | |
| - log.Warn(tagDoHHeader+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| + return nil | |
| +} | |
| - tagDoHRead := getJSONTagByOffset(unsafe.Offsetof(Config{}.LocalDoHReadTimeoutSec)) | |
| - if was := resolvedCfg.LocalDoHReadTimeoutSec; was <= 0 { | |
| - fallback := defaultConfig.LocalDoHReadTimeoutSec | |
| - resolvedCfg.LocalDoHReadTimeoutSec = fallback | |
| - rawCfg.LocalDoHReadTimeoutSec = fallback | |
| - log.Warn(tagDoHRead+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| +// helper to return host (IP or hostname) from an URL | |
| +func hostFromURL(raw string) (string, error) { | |
| + u, err := url.Parse(raw) | |
| + if err != nil { | |
| + return "", fmt.Errorf("failed to parse rawurl %q err: %w", raw, err /*non-nil*/) | |
| } | |
| - | |
| - tagDoHWrite := getJSONTagByOffset(unsafe.Offsetof(Config{}.LocalDoHWriteTimeoutSec)) | |
| - if was := resolvedCfg.LocalDoHWriteTimeoutSec; was <= 0 { | |
| - fallback := defaultConfig.LocalDoHWriteTimeoutSec | |
| - resolvedCfg.LocalDoHWriteTimeoutSec = fallback | |
| - rawCfg.LocalDoHWriteTimeoutSec = fallback | |
| - log.Warn(tagDoHWrite+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + host := u.Hostname() // Built-in method strips the port safely | |
| + if strings.TrimSpace(host) == "" { | |
| + return "", fmt.Errorf("hostname/IP is empty for %q", raw) | |
| } | |
| + return host, nil | |
| +} | |
| - tagDoHIdle := getJSONTagByOffset(unsafe.Offsetof(Config{}.LocalDoHIdleTimeoutSec)) | |
| - if was := resolvedCfg.LocalDoHIdleTimeoutSec; was <= resolvedCfg.LocalDoHReadTimeoutSec { | |
| - fallback := resolvedCfg.LocalDoHReadTimeoutSec * 2 | |
| - resolvedCfg.LocalDoHIdleTimeoutSec = fallback | |
| - rawCfg.LocalDoHIdleTimeoutSec = fallback | |
| - log.Warn(tagDoHIdle+" clamped(to double the read timeout) to prevent premature keep-alive drops", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| +func (s *Server) saveConfig() error { | |
| + log := s.getLogger() | |
| - // ========================================================================= | |
| - // Validate Upstream HTTP Client Idle Connection Pools (Refactor-safe) | |
| - // ========================================================================= | |
| - // ========================================================================= | |
| - // Group 3: Upstream Client & Connection Pools | |
| - // ========================================================================= | |
| - tagUpstreamDialTimeoutSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.UpstreamDialTimeoutSec)) | |
| - if was := resolvedCfg.UpstreamDialTimeoutSec; was <= 0 { | |
| - fallback := defaultConfig.UpstreamDialTimeoutSec | |
| - resolvedCfg.UpstreamDialTimeoutSec = fallback | |
| - rawCfg.UpstreamDialTimeoutSec = fallback | |
| - log.Warn(tagUpstreamDialTimeoutSec+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + rawCfg := s.liveRawConfig.Load() | |
| + if rawCfg == nil { | |
| + // Defensive: should never happen in normal operation. | |
| + panic2("BUG: saveConfig called before liveRawConfig was initialised") | |
| } | |
| - tagUpstreamClientTimeoutSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.UpstreamClientTimeoutSec)) | |
| - // Constraint A: Absolute lower bound check | |
| - if was := resolvedCfg.UpstreamClientTimeoutSec; was <= 0 { | |
| - fallback := defaultConfig.UpstreamClientTimeoutSec | |
| - resolvedCfg.UpstreamClientTimeoutSec = fallback | |
| - rawCfg.UpstreamClientTimeoutSec = fallback | |
| - log.Warn(tagUpstreamClientTimeoutSec+" clamped (prevents infinite hanging client connections)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - // Constraint B: Relational validation check (Sequential, never an 'else if') | |
| - if was := resolvedCfg.UpstreamClientTimeoutSec; was < resolvedCfg.UpstreamDialTimeoutSec { | |
| - fallback := resolvedCfg.UpstreamDialTimeoutSec | |
| - resolvedCfg.UpstreamClientTimeoutSec = fallback | |
| - rawCfg.UpstreamClientTimeoutSec = fallback | |
| - log.Warn(tagUpstreamClientTimeoutSec+" clamped (cannot be less than dial timeout "+tagUpstreamDialTimeoutSec+")", | |
| - slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + data, err := json.MarshalIndent(rawCfg, "", " ") | |
| + if err != nil { | |
| + return fmt.Errorf("config marshal failed: %w", err) | |
| } | |
| - | |
| - tagCertLogTimeoutSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.CertLogTimeoutSec)) | |
| - if was := resolvedCfg.CertLogTimeoutSec; was <= 0 { | |
| - fallback := defaultConfig.CertLogTimeoutSec | |
| - resolvedCfg.CertLogTimeoutSec = fallback | |
| - rawCfg.CertLogTimeoutSec = fallback | |
| - log.Warn(tagCertLogTimeoutSec+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + if err := s.fileWriter.SafeWriteFile(configFileName, data, 0600); err != nil { | |
| + return fmt.Errorf("config write failed: %w", err) | |
| } | |
| + log.Info("Saved config file", slog.String("config_file", configFileName)) | |
| + return nil | |
| +} | |
| - tagUpstreamRetryBackoffMs := getJSONTagByOffset(unsafe.Offsetof(Config{}.UpstreamRetryBackoffMs)) | |
| - if was := resolvedCfg.UpstreamRetryBackoffMs; was <= 0 { | |
| - fallback := defaultConfig.UpstreamRetryBackoffMs | |
| - resolvedCfg.UpstreamRetryBackoffMs = fallback | |
| - rawCfg.UpstreamRetryBackoffMs = fallback | |
| - log.Warn(tagUpstreamRetryBackoffMs+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| +var isAdmin bool // Package level | |
| +func init() { | |
| + // This runs automatically before main() | |
| + // token := windows.GetCurrentProcessToken() | |
| + // isAdmin = token.IsElevated() | |
| + isAdmin = isAdminNow() | |
| +} | |
| - tagUpstreamRetriesPerQuery := getJSONTagByOffset(unsafe.Offsetof(Config{}.UpstreamRetriesPerQuery)) | |
| - if was := resolvedCfg.UpstreamRetriesPerQuery; was < 0 { | |
| - fallback := defaultConfig.UpstreamRetriesPerQuery | |
| - resolvedCfg.UpstreamRetriesPerQuery = fallback | |
| - rawCfg.UpstreamRetriesPerQuery = fallback | |
| - log.Warn(tagUpstreamRetriesPerQuery+" clamped (cannot be negative)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| +func isAdminNow() bool { | |
| + // Windows: Use latest x/sys API for elevation check. | |
| + token := windows.GetCurrentProcessToken() | |
| + elevated := token.IsElevated() // Single bool return | |
| + return elevated | |
| +} | |
| - tagUpstreamIdleConnTimeout := getJSONTagByOffset(unsafe.Offsetof(Config{}.UpstreamIdleConnTimeoutSec)) | |
| - if was := resolvedCfg.UpstreamIdleConnTimeoutSec; was <= 0 { | |
| - fallback := defaultConfig.UpstreamIdleConnTimeoutSec | |
| - resolvedCfg.UpstreamIdleConnTimeoutSec = fallback | |
| - rawCfg.UpstreamIdleConnTimeoutSec = fallback | |
| - log.Warn(tagUpstreamIdleConnTimeout+" clamped (connections stay open indefinitely or drop unpredictably)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| +// initLogging creates the single log with three destinations. | |
| +// Called once after config is loaded (files and console level are known). | |
| +func (s *Server) initFullLogging() *slog.Logger { | |
| + cfg := s.getConfig() | |
| + log := s.getLogger() | |
| - tagUpstreamMaxIdleConns := getJSONTagByOffset(unsafe.Offsetof(Config{}.UpstreamMaxIdleConns)) | |
| - if was := resolvedCfg.UpstreamMaxIdleConns; was <= 0 { | |
| - fallback := defaultConfig.UpstreamMaxIdleConns | |
| - resolvedCfg.UpstreamMaxIdleConns = fallback | |
| - rawCfg.UpstreamMaxIdleConns = fallback | |
| - log.Warn(tagUpstreamMaxIdleConns+" clamped (disables global keep-alive reuse)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| + consoleLevel := parseConsoleLogLevel(cfg.ConsoleLogLevel) | |
| + // Simple rotation on each log line write (respects your LogMaxSizeMB) | |
| + openLog := func(path string) io.Writer { | |
| + if path == "" { | |
| + panic2("BUG: empty logging filename: '" + path + "'") | |
| + } | |
| + path = filepath.Clean(path) | |
| - tagUpstreamMaxIdleConnsPerHost := getJSONTagByOffset(unsafe.Offsetof(Config{}.UpstreamMaxIdleConnsPerHost)) | |
| - // Constraint A: Absolute lower bound check | |
| - if was := resolvedCfg.UpstreamMaxIdleConnsPerHost; was <= 0 { | |
| - fallback := defaultConfig.UpstreamMaxIdleConnsPerHost | |
| - resolvedCfg.UpstreamMaxIdleConnsPerHost = fallback | |
| - rawCfg.UpstreamMaxIdleConnsPerHost = fallback | |
| - log.Warn(tagUpstreamMaxIdleConnsPerHost+" clamped (Go default of 2 severely throttles throughput)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| - // Constraint B: Relational validation check (Sequential, never an 'else if') | |
| - if was := resolvedCfg.UpstreamMaxIdleConnsPerHost; was > resolvedCfg.UpstreamMaxIdleConns { | |
| - // Defensive check: Per-host pool limit can't realistically exceed global pool limit | |
| - fallback := resolvedCfg.UpstreamMaxIdleConns | |
| - resolvedCfg.UpstreamMaxIdleConnsPerHost = fallback | |
| - rawCfg.UpstreamMaxIdleConnsPerHost = fallback | |
| - log.Warn(tagUpstreamMaxIdleConnsPerHost+" clamped (cannot exceed "+tagUpstreamMaxIdleConns+")", | |
| - slog.Int("was", was), | |
| - slog.Int("clamp", fallback), | |
| - slog.Int(tagUpstreamMaxIdleConns, resolvedCfg.UpstreamMaxIdleConns), | |
| - ) | |
| - } | |
| - | |
| - // ========================================================================= | |
| - // Group 4: Local Client & Server Buffer Safeguards | |
| - // ========================================================================= | |
| - tagMaxConcurrentDNSTCPConns := getJSONTagByOffset(unsafe.Offsetof(Config{}.MaxConcurrentDNSTCPConns)) | |
| - if was := resolvedCfg.MaxConcurrentDNSTCPConns; was <= 0 { | |
| - fallback := defaultConfig.MaxConcurrentDNSTCPConns | |
| - resolvedCfg.MaxConcurrentDNSTCPConns = fallback | |
| - rawCfg.MaxConcurrentDNSTCPConns = fallback | |
| - log.Warn(tagMaxConcurrentDNSTCPConns+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| + writer, err := newRotatingLogWriter(path, cfg.LogMaxSizeMB, log) | |
| + if err != nil { | |
| + // We are still in bootstrap phase → use the bootstrap logger so the error is colored | |
| + log.Error("cannot open log file", slog.String("file", path), SafeErr(err)) | |
| + s.shutdown(1) // Keep your existing fatal shutdown here if the initial boot fails | |
| + panic2("BUG: unreachable") | |
| + } | |
| - tagMaxConcurrentDNSUDPQueries := getJSONTagByOffset(unsafe.Offsetof(Config{}.MaxConcurrentDNSUDPQueries)) | |
| - if was := resolvedCfg.MaxConcurrentDNSUDPQueries; was <= 0 { | |
| - fallback := defaultConfig.MaxConcurrentDNSUDPQueries | |
| - resolvedCfg.MaxConcurrentDNSUDPQueries = fallback | |
| - rawCfg.MaxConcurrentDNSUDPQueries = fallback | |
| - log.Warn(tagMaxConcurrentDNSUDPQueries+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| + // We can safely trigger a manual size check/rotation on boot here just in case! | |
| + // The next Write() will rotate it automatically anyway if it's over the limit. | |
| + writer.RotateIfNeeded() | |
| - tagClientTCPTimeoutSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.ClientTCPTimeoutSec)) | |
| - if was := resolvedCfg.ClientTCPTimeoutSec; was <= 0 { | |
| - fallback := defaultConfig.ClientTCPTimeoutSec | |
| - resolvedCfg.ClientTCPTimeoutSec = fallback | |
| - rawCfg.ClientTCPTimeoutSec = fallback | |
| - log.Warn(tagClientTCPTimeoutSec+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + return writer | |
| } | |
| - tagDoHMaxRequestBodyBytes := getJSONTagByOffset(unsafe.Offsetof(Config{}.DoHMaxRequestBodyBytes)) | |
| - if was := resolvedCfg.DoHMaxRequestBodyBytes; was <= 0 { | |
| - fallback := defaultConfig.DoHMaxRequestBodyBytes | |
| - resolvedCfg.DoHMaxRequestBodyBytes = fallback | |
| - rawCfg.DoHMaxRequestBodyBytes = fallback | |
| - log.Warn(tagDoHMaxRequestBodyBytes+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| + fullHandler := slog.NewJSONHandler(openLog(cfg.LogEverythingFile), &slog.HandlerOptions{ | |
| + Level: slog.LevelDebug, // full log gets EVERYTHING | |
| + ReplaceAttr: stripColorTags, // Strips tags safely for files | |
| + }) | |
| - tagDNSUDPBufferSize := getJSONTagByOffset(unsafe.Offsetof(Config{}.DNSUDPBufferSize)) | |
| - if was := resolvedCfg.DNSUDPBufferSize; was < 512 || was > 65535 { | |
| - fallback := defaultConfig.DNSUDPBufferSize | |
| - resolvedCfg.DNSUDPBufferSize = fallback | |
| - rawCfg.DNSUDPBufferSize = fallback | |
| - log.Warn(tagDNSUDPBufferSize+" clamped (must be within standard Ethernet bounds 512-65535)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| + consoleH := NewColoredConsoleHandler(consoleLevel, log) // now uses the real config level | |
| - // ========================================================================= | |
| - // Group 5: Core Engine Limits & Cache Operations | |
| - // ========================================================================= | |
| - tagGlobalRateQPS := getJSONTagByOffset(unsafe.Offsetof(Config{}.GlobalRateQPS)) | |
| - if was := resolvedCfg.GlobalRateQPS; was <= 0 { | |
| - fallback := defaultConfig.GlobalRateQPS | |
| - resolvedCfg.GlobalRateQPS = fallback | |
| - rawCfg.GlobalRateQPS = fallback | |
| - log.Warn(tagGlobalRateQPS+" clamped (must be greater than 0)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + queryH := queryFilterHandler{ | |
| + Handler: slog.NewJSONHandler(openLog(cfg.LogQueriesFile), &slog.HandlerOptions{ | |
| + ReplaceAttr: stripColorTags, // Strips tags safely for files | |
| + }), | |
| } | |
| - tagGlobalBurstQPS := getJSONTagByOffset(unsafe.Offsetof(Config{}.GlobalBurstQPS)) | |
| - // Constraint A: Absolute lower bound check | |
| - if was := resolvedCfg.GlobalBurstQPS; was <= 0 { | |
| - fallback := defaultConfig.GlobalBurstQPS | |
| - resolvedCfg.GlobalBurstQPS = fallback | |
| - rawCfg.GlobalBurstQPS = fallback | |
| - log.Warn(tagGlobalBurstQPS+" clamped (must be greater than 0)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } // else if was < newCfg.GlobalRateQPS { | |
| - // Constraint B: Relational check (Executed sequentially, NEVER as an 'else if') | |
| - if was := resolvedCfg.GlobalBurstQPS; was < resolvedCfg.GlobalRateQPS { | |
| - fallback := resolvedCfg.GlobalRateQPS | |
| - resolvedCfg.GlobalBurstQPS = fallback | |
| - rawCfg.GlobalBurstQPS = fallback | |
| - log.Warn(tagGlobalBurstQPS+" clamped (cannot be less than "+tagGlobalRateQPS+")", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| + improvedLogger := slog.New(multiHandler{ // <-- this REPLACES the global, but it's only used by Server struct and its children | |
| + handlers: []slog.Handler{fullHandler, consoleH, queryH}, | |
| + }) | |
| - tagClientRateQPS := getJSONTagByOffset(unsafe.Offsetof(Config{}.ClientRateQPS)) | |
| - if was := resolvedCfg.ClientRateQPS; was <= 0 { | |
| - fallback := defaultConfig.ClientRateQPS | |
| - resolvedCfg.ClientRateQPS = fallback | |
| - rawCfg.ClientRateQPS = fallback | |
| - log.Warn(tagClientRateQPS+" clamped (must be greater than 0)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| + s.applyLogger(improvedLogger) // all consumers automatically see the new logger | |
| + log = s.getLogger() //to use the new logger on the below log line! | |
| - tagClientBurstQPS := getJSONTagByOffset(unsafe.Offsetof(Config{}.ClientBurstQPS)) | |
| - // Constraint A: Absolute lower bound check | |
| - if was := resolvedCfg.ClientBurstQPS; was <= 0 { | |
| - fallback := defaultConfig.ClientBurstQPS | |
| - resolvedCfg.ClientBurstQPS = fallback | |
| - rawCfg.ClientBurstQPS = fallback | |
| - log.Warn(tagClientBurstQPS+" clamped (must be greater than 0)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } //else if was < newCfg.ClientRateQPS { | |
| - // Constraint B: Relational check (Executed sequentially, NEVER as an 'else if') | |
| - if was := resolvedCfg.ClientBurstQPS; was < resolvedCfg.ClientRateQPS { | |
| - fallback := resolvedCfg.ClientRateQPS | |
| - resolvedCfg.ClientBurstQPS = fallback | |
| - rawCfg.ClientBurstQPS = fallback | |
| - log.Warn(tagClientBurstQPS+" clamped (cannot be less than "+tagClientRateQPS+")", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| + log.Info("Logging initialized", | |
| + slog.String("full_log", cfg.LogEverythingFile), | |
| + slog.String("queries_log", cfg.LogQueriesFile), | |
| + slog.String("console_level", cfg.ConsoleLogLevel), | |
| + ) | |
| + return log | |
| +} | |
| - tagCacheMinTTL := getJSONTagByOffset(unsafe.Offsetof(Config{}.CacheMinTTL)) | |
| - if was := resolvedCfg.CacheMinTTL; was < cacheMinTTLClamp { | |
| - resolvedCfg.CacheMinTTL = cacheMinTTLClamp | |
| - rawCfg.CacheMinTTL = cacheMinTTLClamp | |
| - log.Warn(tagCacheMinTTL+" clamped to safe minimum", slog.Int("was", was), slog.Int("clamp", cacheMinTTLClamp)) | |
| +func getNextLogBackupName(basePath string) string { | |
| + for i := 1; ; i++ { | |
| + backupName := fmt.Sprintf("%s.%d", basePath, i) | |
| + if _, err := os.Stat(backupName); os.IsNotExist(err) { | |
| + return backupName | |
| + } | |
| + // Put a hard cap to avoid infinite loops in extreme edge cases | |
| + if i >= 10000 { | |
| + return fmt.Sprintf("%s.%d", basePath, time.Now().Unix()) | |
| + } | |
| } | |
| +} | |
| - tagCacheMaxEntries := getJSONTagByOffset(unsafe.Offsetof(Config{}.CacheMaxEntries)) | |
| - if was := resolvedCfg.CacheMaxEntries; was <= 0 { | |
| - fallback := defaultConfig.CacheMaxEntries | |
| - resolvedCfg.CacheMaxEntries = fallback | |
| - rawCfg.CacheMaxEntries = fallback | |
| - log.Warn(tagCacheMaxEntries+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| +// func (s *Server) rotateIfNeeded(path string, maxMB int) { | |
| +// log := s.getLogger() | |
| - tagCacheJanitorIntervalMinutes := getJSONTagByOffset(unsafe.Offsetof(Config{}.CacheJanitorIntervalMinutes)) | |
| - if was := resolvedCfg.CacheJanitorIntervalMinutes; was <= 0 { | |
| - fallback := defaultConfig.CacheJanitorIntervalMinutes | |
| - resolvedCfg.CacheJanitorIntervalMinutes = fallback | |
| - rawCfg.CacheJanitorIntervalMinutes = fallback | |
| - log.Warn(tagCacheJanitorIntervalMinutes+" clamped to safe minimum interval", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| +// if fi, err := os.Stat(path); err == nil && fi.Size() > int64(maxMB*1024*1024) { | |
| +// old := path + ".old" | |
| +// if err := os.Rename(path, old); err != nil { | |
| +// log.Error("Log rotation failed", slog.String("path", path), SafeErr(err)) | |
| +// } else { | |
| +// log.Info("Rotated log file", slog.String("path", path), slog.String("old_path", old), slog.Int("max_size_mb", maxMB)) | |
| +// } | |
| +// } | |
| +// } | |
| - tagCacheNegativeTTLSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.CacheNegativeTTLSec)) | |
| - if was := resolvedCfg.CacheNegativeTTLSec; was < 0 { | |
| - fallback := defaultConfig.CacheNegativeTTLSec | |
| - resolvedCfg.CacheNegativeTTLSec = fallback | |
| - rawCfg.CacheNegativeTTLSec = fallback | |
| - log.Warn(tagCacheNegativeTTLSec+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| +func countRules(wl map[string][]RuleEntry) uint64 { | |
| + var total uint64 = 0 | |
| + for _, rs := range wl { | |
| + total += uint64(len(rs)) | |
| } | |
| + return total | |
| +} | |
| - tagBlockedResponseTTLSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.BlockedResponseTTLSec)) | |
| - if was := resolvedCfg.BlockedResponseTTLSec; was < 0 { | |
| - fallback := defaultConfig.BlockedResponseTTLSec | |
| - resolvedCfg.BlockedResponseTTLSec = fallback | |
| - rawCfg.BlockedResponseTTLSec = fallback | |
| - log.Warn(tagBlockedResponseTTLSec+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| +func isLowerASCII(s string) bool { | |
| + for i := 0; i < len(s); i++ { | |
| + c := s[i] | |
| + if c >= 'A' && c <= 'Z' { | |
| + return false | |
| + } | |
| } | |
| + return true | |
| +} | |
| - tagLocalHostsOverrideTTLSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.LocalHostsOverrideTTLSec)) | |
| - if was := resolvedCfg.LocalHostsOverrideTTLSec; was == 0 { | |
| - fallback := defaultConfig.LocalHostsOverrideTTLSec | |
| - resolvedCfg.LocalHostsOverrideTTLSec = fallback | |
| - rawCfg.LocalHostsOverrideTTLSec = fallback | |
| - log.Warn(tagLocalHostsOverrideTTLSec+" clamped", slog.Uint64("was", uint64(was)), slog.Uint64("clamp", uint64(fallback))) | |
| - } | |
| +func panic2(msg string) { | |
| + getBugLogger().Error(msg) | |
| + panic(msg) | |
| +} | |
| - tagMaxRecentBlocks := getJSONTagByOffset(unsafe.Offsetof(Config{}.MaxRecentBlocks)) | |
| - if was := resolvedCfg.MaxRecentBlocks; was <= 0 { | |
| - fallback := defaultConfig.MaxRecentBlocks | |
| - resolvedCfg.MaxRecentBlocks = fallback | |
| - rawCfg.MaxRecentBlocks = fallback | |
| - log.Warn(tagMaxRecentBlocks+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| +// it's assumed that pattern and name are already lowercase(d) or uppercase(d), if not they won't match due to char case difference. | |
| +func matchPattern(pattern, name string) bool { | |
| + if !isLowerASCII(pattern) { | |
| + panic2("BUG: pattern was " + pattern + " which isn't lowercased, so bad coding somewhere!") | |
| } | |
| - | |
| - tagUILogMaxLines := getJSONTagByOffset(unsafe.Offsetof(Config{}.UILogMaxLines)) | |
| - if was := resolvedCfg.UILogMaxLines; was <= 0 { | |
| - fallback := defaultConfig.UILogMaxLines | |
| - resolvedCfg.UILogMaxLines = fallback | |
| - rawCfg.UILogMaxLines = fallback | |
| - log.Warn(tagUILogMaxLines+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + if !isLowerASCII(name) { | |
| + panic2("BUG: name was " + name + " which isn't lowercased, so bad coding somewhere!") | |
| } | |
| - tagLogMaxSizeMB := getJSONTagByOffset(unsafe.Offsetof(Config{}.LogMaxSizeMB)) | |
| - if was := resolvedCfg.LogMaxSizeMB; was <= 0 { | |
| - fallback := defaultConfig.LogMaxSizeMB | |
| - resolvedCfg.LogMaxSizeMB = fallback | |
| - rawCfg.LogMaxSizeMB = fallback | |
| - log.Warn(tagLogMaxSizeMB+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| - } | |
| + // Fallback to recursive matching for other tokens ({*}, *, ?, !, literal text) | |
| + return recursiveMatch(pattern, name) | |
| +} | |
| - // ========================================================================= | |
| - // IP Strings Parsing & Post-Processing Operations | |
| - // ========================================================================= | |
| - // Clean up and pre-parse IPv4 | |
| - //TODO: do I have to set the parseds to rawCfg too?! | |
| - if ip := net.ParseIP(resolvedCfg.BlockIP); ip != nil && ip.To4() != nil { | |
| - resolvedCfg.BlockIPv4Parsed = ip.To4() | |
| - rawCfg.BlockIPv4Parsed = ip.To4() | |
| - } else { | |
| - const IP0 = "0.0.0.0" | |
| - const zero = 0 | |
| - resolvedCfg.BlockIP = IP0 | |
| - rawCfg.BlockIP = IP0 | |
| - resolvedCfg.BlockIPv4Parsed = net.IPv4(zero, zero, zero, zero).To4() | |
| - rawCfg.BlockIPv4Parsed = resolvedCfg.BlockIPv4Parsed // TODO: hopefully no need to have diff. instances of this here? | |
| - } | |
| +// recursiveMatch handles all tokens recursively. | |
| +func recursiveMatch(pattern, name string) bool { | |
| + for len(pattern) > 0 { | |
| + switch { | |
| + case strings.HasPrefix(pattern, "{**}"): | |
| + // consume 1+ chars including dots | |
| + pattern = pattern[4:] | |
| + if len(name) < 1 { | |
| + return false | |
| + } | |
| + for i := 1; i <= len(name); i++ { | |
| + if recursiveMatch(pattern, name[i:]) { | |
| + return true | |
| + } | |
| + } | |
| + return false | |
| - // Clean up and pre-parse IPv6 | |
| - if ip := net.ParseIP(resolvedCfg.BlockIPv6); ip != nil && ip.To16() != nil { | |
| - resolvedCfg.BlockIPv6Parsed = ip.To16() | |
| - rawCfg.BlockIPv6Parsed = ip.To16() // TODO: do we need this to be same instance for equals to say equals anywhere? | |
| - } else { | |
| - const IPv6Zero = "::" | |
| - resolvedCfg.BlockIPv6 = IPv6Zero | |
| - resolvedCfg.BlockIPv6Parsed = net.ParseIP(IPv6Zero).To16() | |
| - rawCfg.BlockIPv6Parsed = resolvedCfg.BlockIPv6Parsed // TODO: hopefully no need to have diff. instances of this here? | |
| - } | |
| - | |
| - // Validate ListenDoH host is a literal IP (required for TLS cert SAN) | |
| - tagListenDoH := getJSONTagByOffset(unsafe.Offsetof(Config{}.ListenDoH)) | |
| - if doHHost, _, splitErr := net.SplitHostPort(resolvedCfg.ListenDoH); splitErr != nil { | |
| - return fmt.Errorf("%q %q is not a valid host:port, actually must be IP:port, err: %w", tagListenDoH, resolvedCfg.ListenDoH, splitErr) | |
| - } else if net.ParseIP(doHHost) == nil { | |
| - return fmt.Errorf("%q host %q must be an IP literal with no surrounding spaces (not a hostname(because we can't look it up without DNS)) for TLS cert generation", tagListenDoH, doHHost) | |
| - } | |
| - if doHHost, _, splitErr := net.SplitHostPort(resolvedCfg.ListenUI); splitErr != nil { | |
| - return fmt.Errorf("%q %q is not a valid host:port, actually must be IP:port, err: %w", tagListenUI, resolvedCfg.ListenUI, splitErr) | |
| - } else if net.ParseIP(doHHost) == nil { | |
| - return fmt.Errorf("%q host %q must be an IP literal with no surrounding spaces (not a hostname(because we can't look it up without DNS)) for TLS cert generation", tagListenUI, doHHost) | |
| - } | |
| - | |
| - resolvedCfg.BlockMode = strings.ToLower(resolvedCfg.BlockMode) //XXX: lowercasing this for future comparisons to be easier! | |
| - rawCfg.BlockMode = resolvedCfg.BlockMode | |
| - //TODO: ensure only valid values are used here for config.BlockMode or warn/exit! | |
| + case strings.HasPrefix(pattern, "**"): | |
| + // consume 0+ chars including dots | |
| + pattern = pattern[2:] | |
| + if len(name) == 0 { | |
| + return recursiveMatch(pattern, "") | |
| + } | |
| + for i := 0; i <= len(name); i++ { | |
| + if recursiveMatch(pattern, name[i:]) { | |
| + return true | |
| + } | |
| + } | |
| + return false | |
| - //TODO: see if I've to shouldSaveConfig for anything else here, above maybe? | |
| + case strings.HasPrefix(pattern, "{*}"): | |
| + // consume 1+ chars, stop at dot | |
| + pattern = pattern[3:] | |
| + max3 := 0 | |
| + for j := 0; j < len(name) && name[j] != '.'; j++ { | |
| + max3 = j + 1 | |
| + } | |
| + if max3 < 1 { | |
| + return false | |
| + } | |
| + for i := 1; i <= max3; i++ { | |
| + if recursiveMatch(pattern, name[i:]) { | |
| + return true | |
| + } | |
| + } | |
| + return false | |
| - if len(resolvedCfg.UpstreamSNIHostnames) > len(resolvedCfg.UpstreamURLs) { | |
| - const msg = "there are more SNIs vs URLs for upstream, only the opposite is allowed ( >= URLs than SNIs which then inherit the SNI from URLs)" | |
| - log.Warn(msg) | |
| - return fmt.Errorf("%s", msg) | |
| - } | |
| - // Ensure SNIHostnames has the same length as UpstreamURLs, falling back to the URL's hostname | |
| - for i := len(resolvedCfg.UpstreamSNIHostnames); i < len(resolvedCfg.UpstreamURLs); i++ { | |
| - host, err2 := hostFromURL(resolvedCfg.UpstreamURLs[i]) | |
| - if err2 != nil { | |
| - log.Warn("invalid upstream URL during SNI fill", slog.Int("index", i), SafeErr(err2)) | |
| - return fmt.Errorf("invalid upstream URL at index %d: %w", i, err2) | |
| - } | |
| - rawCfg.UpstreamSNIHostnames = append(rawCfg.UpstreamSNIHostnames, host) | |
| - resolvedCfg.UpstreamSNIHostnames = append(resolvedCfg.UpstreamSNIHostnames, host) | |
| - shouldSaveConfig = true | |
| - } | |
| - //FIXME: this is weird, what are we doing here below vs above?! | |
| - for i := range resolvedCfg.UpstreamURLs { | |
| - if resolvedCfg.UpstreamSNIHostnames[i] != "" { | |
| - continue | |
| - } | |
| + case strings.HasPrefix(pattern, "*"): | |
| + // consume 0+ chars, stop at dot | |
| + pattern = pattern[1:] | |
| + if len(name) == 0 { | |
| + return recursiveMatch(pattern, "") | |
| + } | |
| + for i := 0; i <= len(name); i++ { | |
| + if i < len(name) && name[i] == '.' { | |
| + if recursiveMatch(pattern, name[i:]) { | |
| + return true | |
| + } | |
| + break | |
| + } | |
| + if recursiveMatch(pattern, name[i:]) { | |
| + return true | |
| + } | |
| + } | |
| + return false | |
| - host, err2 := hostFromURL(resolvedCfg.UpstreamURLs[i]) | |
| - if err2 != nil { | |
| - log.Error("invalid upstream URL", slog.Int("at_index", i), SafeErr(err2)) | |
| - return fmt.Errorf("invalid upstream URL at index %d: %w", i, err2) | |
| - } | |
| - rawCfg.UpstreamSNIHostnames[i] = host | |
| - resolvedCfg.UpstreamSNIHostnames[i] = host | |
| - shouldSaveConfig = true | |
| - } | |
| - log.Debug("Using upstream SNI hostnames:", | |
| - SafeStringSlice("SNI_hostnames", resolvedCfg.UpstreamSNIHostnames), | |
| - ) | |
| + case strings.HasPrefix(pattern, "?"): | |
| + // consume exactly 1 char, not dot | |
| + if len(name) == 0 || name[0] == '.' { | |
| + return false | |
| + } | |
| + pattern = pattern[1:] | |
| + name = name[1:] | |
| - // Helper closure to apply the cleaning and track if a save is needed | |
| - checkAndClean := func(resolvedTarget, rawTarget *string, configKey, fallback string) { | |
| - if cleaned, changed := s.cleanFileName(*resolvedTarget, configKey, fallback); changed { | |
| - if *resolvedTarget != *rawTarget { | |
| - s.logFatal2(fmt.Sprintf("Won't overwrite template %q with cleaned value %q, you must do it manually then rerun.", *rawTarget, *resolvedTarget)) //FIXME: we should be able to do this? but this means either write into the file, or set the env.var. | |
| + case strings.HasPrefix(pattern, "!"): | |
| + // consume exactly 1 char, any | |
| + if len(name) == 0 { | |
| + return false | |
| } | |
| - *resolvedTarget = cleaned | |
| - *rawTarget = cleaned | |
| - if !shouldSaveConfig { | |
| - shouldSaveConfig = true | |
| + pattern = pattern[1:] | |
| + name = name[1:] | |
| + | |
| + default: | |
| + // literal char match | |
| + if len(name) == 0 || pattern[0] != name[0] { | |
| + return false | |
| } | |
| + pattern = pattern[1:] | |
| + name = name[1:] | |
| } | |
| } | |
| - checkAndClean(&resolvedCfg.BlacklistFile, &rawCfg.BlacklistFile, getJSONTagByOffset(unsafe.Offsetof(Config{}.BlacklistFile)), defaultConfig.BlacklistFile) | |
| - checkAndClean(&resolvedCfg.WhitelistFile, &rawCfg.WhitelistFile, getJSONTagByOffset(unsafe.Offsetof(Config{}.WhitelistFile)), defaultConfig.WhitelistFile) | |
| - checkAndClean(&resolvedCfg.LogQueriesFile, &rawCfg.LogQueriesFile, getJSONTagByOffset(unsafe.Offsetof(Config{}.LogQueriesFile)), defaultConfig.LogQueriesFile) | |
| - checkAndClean(&resolvedCfg.LogEverythingFile, &rawCfg.LogEverythingFile, getJSONTagByOffset(unsafe.Offsetof(Config{}.LogEverythingFile)), defaultConfig.LogEverythingFile) | |
| - checkAndClean(&resolvedCfg.HostsFile, &rawCfg.HostsFile, getJSONTagByOffset(unsafe.Offsetof(Config{}.HostsFile)), defaultConfig.HostsFile) | |
| - | |
| - // NEW: Enforce password setup if it's missing from the config | |
| - if resolvedCfg.WebUIPasswordHash == "" { | |
| - log.Warn("No WebUI password configured. Securing WebUI now...") | |
| - fmt.Println("\n========================================================") | |
| - fmt.Println(" INITIAL SETUP: SECURING YOUR WEB CONTROL PANEL ") | |
| - fmt.Println("========================================================") | |
| - hash, err2 := promptAndHashPassword(log) | |
| - if err2 != nil { | |
| - s.logFatal2("Failed to setup password (aborted): " + err2.Error()) | |
| - panic2("BUG: unreachable") | |
| - } | |
| + return len(name) == 0 | |
| +} | |
| - // Update live config instance | |
| - resolvedCfg.WebUIPasswordHash = hash | |
| - rawCfg.WebUIPasswordHash = hash | |
| +// generate a cert that's valid for both local DoH listener and for webUI | |
| +func (s *Server) generateCertIfNeeded() { | |
| + log := s.getLogger() | |
| + cfg := s.getConfig() | |
| - log.Info("WebUI password successfully set.") | |
| - if !shouldSaveConfig { | |
| - shouldSaveConfig = true | |
| - } | |
| - } | |
| + log.Debug("check if cert is valid or needs regen") | |
| + const certFile = "cert.pem" | |
| + const keyFile = "key.pem" | |
| - // Apply the fully validated config atomically | |
| - // 2. APPLY THE VALIDATED CONFIG ATOMICALLY | |
| - // From this exact microsecond, all new DNS queries will use the clamped, safe config. | |
| - s.liveRawConfig.Store(rawCfg) | |
| - s.applyConfig(*resolvedCfg) | |
| - if shouldSaveConfig { | |
| - // saveConfig internally calls s.getConfig(), which now has the fully updated data | |
| - if err = s.saveConfig(); err != nil { | |
| - return fmt.Errorf("config save failed: %w", err) | |
| - } | |
| - } | |
| - // 4. LOG STRATEGY | |
| - // Add your new clear architectural description line here: | |
| - switch resolvedCfg.UpstreamSelectionMode { | |
| - case "strict": | |
| - log.Info("Upstream DNS strategy initialized: STRICT MATCH MODE (All upstreams queried; queries will be safely dropped if response IPs mismatch to protect against manipulation/spoofing; WARNING: Virtually unusable on standard networks due to false-positive drops caused by modern CDNs, Geo-DNS routing, and load balancers returning different IPs for identical queries.).") | |
| - case "failover": | |
| - log.Info("Upstream DNS strategy initialized: FAILOVER MODE (Sticky sequence tracking; queries the current active upstream and all higher-priority(first in list are higher prio.) failed upstreams in parallel to eliminate timeout penalties while instantly healing and restoring primary upstreams the moment they recover.).") | |
| - case "fastest": | |
| - //nolint:gocritic // Reason: Keeping 'fastest' explicit for readability | |
| - fallthrough | |
| - default: | |
| - log.Info("Upstream DNS strategy initialized: FASTEST WINS MODE (Racing upstreams concurrently; the first successful response is accepted immediately to optimize for CDNs, Geo-DNS, and speed).") | |
| - } | |
| - //so above was load config.json | |
| - return nil | |
| -} | |
| + needsRegen := false | |
| -func (s *Server) loadConfig() error { | |
| - var err error = s.loadMainConfig() | |
| - if err != nil { | |
| - return err | |
| - } | |
| - // After decoding and applying config, because these use it: | |
| - // 3. LOAD DEPENDENT FILES | |
| - // Now that s.getConfig() returns the NEW config, these will use the correct file paths. | |
| - err = s.loadQueryWhitelist() | |
| + var err error | |
| + // Extract the host/IP from the DoH listener address | |
| + dohHost, _, err := net.SplitHostPort(cfg.ListenDoH) | |
| if err != nil { | |
| - return err | |
| + dohHost = cfg.ListenDoH | |
| } | |
| - err = s.loadResponseBlacklist() | |
| + | |
| + // Extract the host/IP from the Web UI listener address | |
| + uiHost, _, err := net.SplitHostPort(cfg.ListenUI) | |
| if err != nil { | |
| - return err | |
| - } | |
| - if err = s.loadLocalHosts(); err != nil { | |
| - return err | |
| + uiHost = cfg.ListenUI | |
| } | |
| - return nil | |
| -} | |
| - | |
| -// cleanFileName returns the cleaned filename and a boolean indicating if the original was modified. | |
| -func (s *Server) cleanFileName(original, configKey, fallback string) (string, bool) { | |
| - log := s.getLogger() | |
| - | |
| - if original == "" { | |
| - if fallback == "" { | |
| - msg := fmt.Sprintf("BUG: dev fail: passed empty filename to clean for config key %q and the fallback was also empty!", configKey) | |
| - log.Error(msg, slog.String("config_key", configKey)) | |
| - panic(msg) | |
| - } | |
| - log.Warn("Bad filename in config, used fallback", | |
| - slog.String("for_config_key", configKey), | |
| - slog.String("bad_filename", original), | |
| - slog.String("fallback_filename", fallback)) | |
| + //In Go, net.ParseIP is a strict parser. It does not perform DNS lookups; it only checks if the string is a valid IPv4 or IPv6 literal. If you pass it "localhost", it returns nil. | |
| - // Ensure the fallback itself is clean before returning | |
| - cleaned := filepath.Clean(fallback) //FIXME: not a fan of having to call Clean twice, for DRY purposes. | |
| - if cleaned != fallback { | |
| - msg := fmt.Sprintf("BUG: dev fail: fallback(%q) for config key %q had to be cleaned into %q", fallback, configKey, cleaned) | |
| - log.Error(msg, slog.String("config_key", configKey), slog.String("fallback_filename", fallback), slog.String("filename_cleaned", cleaned)) | |
| - panic(msg) | |
| - } | |
| - return cleaned, true | |
| + // STRICT IP ENFORCEMENT: Hostnames are strictly forbidden because | |
| + // they cannot be resolved before this local DNS proxy actually starts. | |
| + if net.ParseIP(dohHost) == nil { | |
| + panic2("BUG: config error: config.ListenDoH host part MUST be an IP literal. Hostnames are forbidden. Invalid value: " + dohHost) | |
| } | |
| - cleaned := filepath.Clean(original) | |
| - // Reject Windows reserved device names (CON, NUL, COM1, etc.). | |
| - // filepath.Base handles any directory prefix; TrimRight strips trailing | |
| - // dots and spaces that Windows itself strips before resolving the name. | |
| - baseName := strings.ToUpper(strings.TrimRight(filepath.Base(cleaned), ". ")) | |
| - if _, reserved := reservedNames[baseName]; reserved { | |
| - log.Warn("Config filename is a reserved Windows device name; using fallback", | |
| - slog.String("for_config_key", configKey), | |
| - slog.String("reserved_filename", cleaned), | |
| - slog.String("fallback_filename", fallback)) | |
| - return filepath.Clean(fallback), true | |
| - } | |
| - if cleaned != original { | |
| - log.Debug("Cleaned filename from config file", | |
| - slog.String("config_key", configKey), | |
| - slog.String("filename_before", original), | |
| - slog.String("filename_after", cleaned)) | |
| - return cleaned, true | |
| + if net.ParseIP(uiHost) == nil { | |
| + panic2("BUG: config error: config.ListenUI host part MUST be an IP literal. Hostnames are forbidden. Invalid value: " + uiHost) | |
| } | |
| - return original, false | |
| -} | |
| - | |
| -// helper to return host (IP or hostname) from an URL | |
| -func hostFromURL(raw string) (string, error) { | |
| - u, err := url.Parse(raw) | |
| - if err != nil { | |
| - return "", fmt.Errorf("failed to parse rawurl %q err: %w", raw, err /*non-nil*/) | |
| - } | |
| - host := u.Hostname() // Built-in method strips the port safely | |
| - if strings.TrimSpace(host) == "" { | |
| - return "", fmt.Errorf("hostname/IP is empty for %q", raw) | |
| - } | |
| - return host, nil | |
| -} | |
| - | |
| -func (s *Server) saveConfig() error { | |
| - log := s.getLogger() | |
| - | |
| - rawCfg := s.liveRawConfig.Load() | |
| - if rawCfg == nil { | |
| - // Defensive: should never happen in normal operation. | |
| - panic2("BUG: saveConfig called before liveRawConfig was initialised") | |
| + // Build the list of requiredHosts/IPs that must be covered by the certificate | |
| + // Build the deduplicated required-hosts slice. | |
| + requiredHosts := []string{dohHost} | |
| + if cfg.WebUIUseTLS && uiHost != dohHost { | |
| + // WebUI host is only relevant when TLS is enabled for the WebUI. | |
| + // When WebUI runs plain HTTP, it never uses s.dohCert, so no SAN needed. | |
| + //also dedup | |
| + requiredHosts = append(requiredHosts, uiHost) | |
| } | |
| - data, err := json.MarshalIndent(rawCfg, "", " ") | |
| + // 2. Check if cert exists and is still valid for this IP | |
| + certBytes, err := os.ReadFile(certFile) | |
| if err != nil { | |
| - return fmt.Errorf("config marshal failed: %w", err) | |
| + // File missing or unreadable | |
| + log.Warn("Cert file doesn't exist", slog.String("file", certFile), SafeErr(err)) // no \n | |
| + needsRegen = true | |
| + } else { | |
| + // Parse the PEM | |
| + block, _ := pem.Decode(certBytes) | |
| + if block == nil { | |
| + log.Warn("Cert file had empty decoded block.", slog.String("file", certFile)) // no \n | |
| + needsRegen = true | |
| + } else { | |
| + cert, err2 := x509.ParseCertificate(block.Bytes) | |
| + if err2 != nil { | |
| + log.Warn("Cert file failed parsing", slog.String("file", certFile), SafeErr(err2)) // no \n | |
| + needsRegen = true | |
| + } else { | |
| + // Verify that ALL required hosts are present in the existing certificate's SAN list | |
| + for _, h := range requiredHosts { | |
| + found := false | |
| + parsedIP := net.ParseIP(h) | |
| + if parsedIP != nil { | |
| + // Check IP list | |
| + for _, ip := range cert.IPAddresses { | |
| + if ip.Equal(parsedIP) { | |
| + found = true | |
| + break | |
| + } | |
| + } | |
| + } else { | |
| + // Check DNS list | |
| + for _, name := range cert.DNSNames { | |
| + if name == h { | |
| + found = true | |
| + break | |
| + } | |
| + } | |
| + } | |
| + if !found { | |
| + log.Warn("Cert identity mismatch", slog.String("want", h), slog.Any("haveIPs", cert.IPAddresses), slog.Any("haveDNSNames", cert.DNSNames)) | |
| + needsRegen = true | |
| + break | |
| + } | |
| + } | |
| + } | |
| + } | |
| } | |
| - if err := s.fileWriter.SafeWriteFile(configFileName, data, 0600); err != nil { | |
| - return fmt.Errorf("config write failed: %w", err) | |
| + | |
| + // 3. Regen if necessary | |
| + if needsRegen { | |
| + log.Warn("Due to above, regenerating self-signed cert ...", slog.String("public_key_aka_cert_file", certFile), slog.String("private_key_file", keyFile), | |
| + slog.Any("hosts", requiredHosts)) | |
| + if err = s.generateCert(certFile, keyFile, requiredHosts); err != nil { | |
| + //done: need to unify logging errors in log and on console somehow, this printf and errorLogger thing is a mess. | |
| + s.logFatal("cert generation failed", err) | |
| + panic2("BUG: unreachable") | |
| + } | |
| + s.certGeneration.Add(1) // <-- Increment here instead of returning true | |
| + // Build proper guidance message based on whether Web UI TLS is enabled | |
| + var msg strings.Builder | |
| + msg.WriteString("Cert generated: make sure you trust it in clients. ") | |
| + if cfg.WebUIUseTLS { | |
| + fmt.Fprintf(&msg, "For browsers, load the Web UI HTTPS URL: https://%s/ and add a certificate exception, or manually trust this endpoint via your browser's Certificate Manager. ", cfg.ListenUI) | |
| + } else { | |
| + fmt.Fprintf(&msg, "Web UI is configured with unencrypted HTTP: http://%s/ . ", cfg.ListenUI) | |
| + } | |
| + fmt.Fprintf(&msg, "For DoH clients, specify the server URL: https://%s/dns-query", cfg.ListenDoH) | |
| + log.Warn(msg.String(), | |
| + slog.String("doh_url", fmt.Sprintf("https://%s/dns-query", cfg.ListenDoH)), | |
| + slog.String(getJSONTagByOffset(unsafe.Offsetof(Config{}.ListenUI)), cfg.ListenUI)) | |
| + } else { | |
| + log.Debug("Existing cert is valid for host. Skipping generation.", slog.Any("hosts", requiredHosts)) | |
| } | |
| - log.Info("Saved config file", slog.String("config_file", configFileName)) | |
| - return nil | |
| -} | |
| -var isAdmin bool // Package level | |
| -func init() { | |
| - // This runs automatically before main() | |
| - // token := windows.GetCurrentProcessToken() | |
| - // isAdmin = token.IsElevated() | |
| - isAdmin = isAdminNow() | |
| -} | |
| + // Load cert/key into global for reuse | |
| + log.Info("Loading cert/key for DoH and Web UI...") | |
| -func isAdminNow() bool { | |
| - // Windows: Use latest x/sys API for elevation check. | |
| - token := windows.GetCurrentProcessToken() | |
| - elevated := token.IsElevated() // Single bool return | |
| - return elevated | |
| + s.dohCert, err = tls.LoadX509KeyPair(certFile, keyFile) | |
| + if err != nil { | |
| + s.logFatal("cert_load_failed", err) | |
| + panic2("BUG: unreachable") | |
| + } | |
| + log.Info("Success - loaded into tls.Certificate") | |
| } | |
| -// initLogging creates the single log with three destinations. | |
| -// Called once after config is loaded (files and console level are known). | |
| -func (s *Server) initFullLogging() *slog.Logger { | |
| - cfg := s.getConfig() | |
| +// 'host' can be localhost or 127.0.0.1 for example, but it won't be looked up! | |
| +func (s *Server) generateCert(certFileNameNoPath, keyFileNameNoPath string, hosts []string) error { | |
| log := s.getLogger() | |
| + if certFileNameNoPath == "" || keyFileNameNoPath == "" { | |
| + panic2("BUG: unexpected empty filename(s) for cert,key: '" + certFileNameNoPath + "','" + keyFileNameNoPath + "'") | |
| + } | |
| + if len(hosts) == 0 { | |
| + panic2("BUG: generateCert: hosts slice is empty — nothing to put in the SAN") | |
| + } | |
| + certFileNameNoPath = filepath.Clean(certFileNameNoPath) | |
| + keyFileNameNoPath = filepath.Clean(keyFileNameNoPath) | |
| + // From crypto/tls/generate_cert.go; edge: Ensure unique serial, valid for 10y | |
| + priv, err := rsa.GenerateKey(rand.Reader, 2048) | |
| + if err != nil { | |
| + return fmt.Errorf("key gen failed: %w", err) | |
| + } | |
| + serial := big.NewInt(0) | |
| + // Strip hyphens so it's a valid hex string | |
| + hexUUID := strings.ReplaceAll(uuid.New().String(), "-", "") | |
| + serial.SetString(hexUUID, 16) // Unique serial | |
| + certTemplate := x509.Certificate{ | |
| + SerialNumber: serial, | |
| + Subject: pkix.Name{ | |
| + Organization: []string{"DNSbollocks ie. Local DNS Proxy"}, | |
| + }, | |
| + NotBefore: time.Now(), | |
| + NotAfter: time.Now().Add(365 * 24 * time.Hour * 10), | |
| + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, | |
| + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, | |
| + BasicConstraintsValid: true, //"Explicitly write the Basic Constraints extension into the certificate metadata, and mark IsCA as false(or as it's set below)." | |
| + IsCA: false, // Defaults to false if not specified | |
| + } | |
| - consoleLevel := parseConsoleLogLevel(cfg.ConsoleLogLevel) | |
| - // Simple rotation on each log line write (respects your LogMaxSizeMB) | |
| - openLog := func(path string) io.Writer { | |
| - if path == "" { | |
| - panic2("BUG: empty logging filename: '" + path + "'") | |
| + // Populate IPAddresses and DNSNames dynamically for all requested hosts | |
| + // Deduplicate hosts before adding to SAN to avoid malformed certs. | |
| + seenIPs := make(map[string]struct{}) | |
| + seenDNS := make(map[string]struct{}) | |
| + for _, host := range hosts { | |
| + host = strings.TrimSpace(host) | |
| + if host == "" { | |
| + continue | |
| } | |
| - path = filepath.Clean(path) | |
| - | |
| - writer, err := newRotatingLogWriter(path, cfg.LogMaxSizeMB, log) | |
| - if err != nil { | |
| - // We are still in bootstrap phase → use the bootstrap logger so the error is colored | |
| - log.Error("cannot open log file", slog.String("file", path), SafeErr(err)) | |
| - s.shutdown(1) // Keep your existing fatal shutdown here if the initial boot fails | |
| - panic2("BUG: unreachable") | |
| + if ip := net.ParseIP(host); ip != nil { | |
| + key := ip.String() // normalise e.g. "::1" vs "0:0:0:0:0:0:0:1" | |
| + if _, dup := seenIPs[key]; !dup { | |
| + seenIPs[key] = struct{}{} | |
| + certTemplate.IPAddresses = append(certTemplate.IPAddresses, ip) | |
| + } | |
| + } else { | |
| + if _, dup := seenDNS[host]; !dup { | |
| + seenDNS[host] = struct{}{} | |
| + certTemplate.DNSNames = append(certTemplate.DNSNames, host) | |
| + } | |
| } | |
| - // We can safely trigger a manual size check/rotation on boot here just in case! | |
| - // The next Write() will rotate it automatically anyway if it's over the limit. | |
| - writer.RotateIfNeeded() | |
| - | |
| - return writer | |
| + } | |
| + if len(certTemplate.IPAddresses) == 0 && len(certTemplate.DNSNames) == 0 { | |
| + // All hosts were empty or whitespace after trimming — programmer error. | |
| + panic2(fmt.Sprintf("BUG: generateCert: no valid SANs could be built from hosts %v", hosts)) | |
| } | |
| - fullHandler := slog.NewJSONHandler(openLog(cfg.LogEverythingFile), &slog.HandlerOptions{ | |
| - Level: slog.LevelDebug, // full log gets EVERYTHING | |
| - ReplaceAttr: stripColorTags, // Strips tags safely for files | |
| - }) | |
| + derBytes, err := x509.CreateCertificate(rand.Reader, &certTemplate, &certTemplate, &priv.PublicKey, priv) | |
| + if err != nil { | |
| + return fmt.Errorf("cert create failed: %w", err) | |
| + } | |
| - consoleH := NewColoredConsoleHandler(consoleLevel, log) // now uses the real config level | |
| + // not this way: #nosec G304 | |
| + certOut, err := os.Create(certFileNameNoPath) | |
| + if err != nil { | |
| + return fmt.Errorf("cert write failed: %w", err) | |
| + } else { | |
| + defer func() { | |
| + if closeErr := certOut.Close(); closeErr != nil { | |
| + log.Error("failed to close cert public key file (incompletely written to disk then?)", SafeErr(closeErr), slog.String("filename", certFileNameNoPath)) | |
| + } | |
| + }() | |
| + } | |
| + if err = pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil { | |
| + return fmt.Errorf("pem encode cert failed: %w", err) | |
| + } | |
| - queryH := queryFilterHandler{ | |
| - Handler: slog.NewJSONHandler(openLog(cfg.LogQueriesFile), &slog.HandlerOptions{ | |
| - ReplaceAttr: stripColorTags, // Strips tags safely for files | |
| - }), | |
| + //keyOut, err := os.Create(keyFile) | |
| + // 2. Fix the Key Permissions: Replace os.Create(keyFile) with this: | |
| + // not this way: #nosec G304 but this way filepath.Clean( | |
| + keyOut, err := os.OpenFile(keyFileNameNoPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) | |
| + if err != nil { | |
| + return fmt.Errorf("key write failed: %w", err) | |
| + } else { | |
| + defer func() { | |
| + if closeErr := keyOut.Close(); closeErr != nil { | |
| + log.Error("failed to close cert private key file (incompletely written to disk then?)", SafeErr(closeErr), slog.String("filename", keyFileNameNoPath)) | |
| + } | |
| + }() | |
| + } | |
| + if err := pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}); err != nil { | |
| + return fmt.Errorf("pem encode key failed: %w", err) | |
| } | |
| + return nil | |
| +} | |
| - improvedLogger := slog.New(multiHandler{ // <-- this REPLACES the global, but it's only used by Server struct and its children | |
| - handlers: []slog.Handler{fullHandler, consoleH, queryH}, | |
| - }) | |
| +type contextKey string | |
| - s.applyLogger(improvedLogger) // all consumers automatically see the new logger | |
| - log = s.getLogger() //to use the new logger on the below log line! | |
| +const clientInfoKey contextKey = "clientInfo" | |
| - log.Info("Logging initialized", | |
| - slog.String("full_log", cfg.LogEverythingFile), | |
| - slog.String("queries_log", cfg.LogQueriesFile), | |
| - slog.String("console_level", cfg.ConsoleLogLevel), | |
| - ) | |
| - return log | |
| -} | |
| +type clientMetadata struct { | |
| + protocol string | |
| + pid uint32 | |
| + exe string | |
| + services []string | |
| + err error | |
| + clientAddr net.Addr | |
| + startTime time.Time | |
| +} | |
| -func getNextLogBackupName(basePath string) string { | |
| - for i := 1; ; i++ { | |
| - backupName := fmt.Sprintf("%s.%d", basePath, i) | |
| - if _, err := os.Stat(backupName); os.IsNotExist(err) { | |
| - return backupName | |
| - } | |
| - // Put a hard cap to avoid infinite loops in extreme edge cases | |
| - if i >= 10000 { | |
| - return fmt.Sprintf("%s.%d", basePath, time.Now().Unix()) | |
| +// Listeners... | |
| + | |
| +func (s *Server) makeClientInfoContext(ctx context.Context, protocol string, clientAddr net.Addr, pid uint32, exe string, err error) context.Context { | |
| + log := s.getLogger() | |
| + | |
| + var services []string | |
| + var serviceInfo string | |
| + if err != nil { | |
| + log.Warn("couldn't get pid and exe name", | |
| + slog.String("proto", protocol), | |
| + //slog.String("clientAddr", clientAddr.String()), | |
| + SafeAddr("clientAddr", clientAddr), | |
| + SafeErr(err)) | |
| + //services = []string{"<err:no_pid>"} | |
| + //return ctx | |
| + serviceInfo = "err:no_pid" | |
| + } else { | |
| + //fmt.Println("!before") | |
| + //services, err := wincoe.GetServiceNamesFromPIDCached(pid) // this epic shadowing with no warnings! (golangci-lint v2 is broken when using v1.27 devel Go) and vscode didn't say anything on its own. | |
| + services, err = wincoe.GetServiceNamesFromPIDCached(pid) | |
| + //services = []string{"<service-lookup-disabled-for-debug>"} | |
| + //fmt.Println("!after") | |
| + if err != nil { | |
| + serviceInfo = fmt.Sprintf("err=%v", err) | |
| + } else { | |
| + serviceInfo = fmt.Sprintf("%v", services) | |
| + // if len(services) > 0 { | |
| + // serviceInfo = fmt.Sprintf("%d services: %v", len(services), services) | |
| + // } else { | |
| + // serviceInfo = "no services" | |
| + // } | |
| } | |
| } | |
| -} | |
| -// func (s *Server) rotateIfNeeded(path string, maxMB int) { | |
| -// log := s.getLogger() | |
| + log.Debug("client connected", | |
| + slog.String("proto", protocol), | |
| + //slog.String("clientAddr", clientAddr.String()), | |
| + SafeAddr("clientAddr", clientAddr), | |
| + slog.Int64("pid", int64(pid)), | |
| + slog.String("exe", exe), | |
| + slog.String("services", serviceInfo), | |
| + SafeErr(err), | |
| + ) | |
| -// if fi, err := os.Stat(path); err == nil && fi.Size() > int64(maxMB*1024*1024) { | |
| -// old := path + ".old" | |
| -// if err := os.Rename(path, old); err != nil { | |
| -// log.Error("Log rotation failed", slog.String("path", path), SafeErr(err)) | |
| -// } else { | |
| -// log.Info("Rotated log file", slog.String("path", path), slog.String("old_path", old), slog.Int("max_size_mb", maxMB)) | |
| -// } | |
| -// } | |
| -// } | |
| + // Create a specific context for THIS packet | |
| + return context.WithValue(ctx, clientInfoKey, clientMetadata{ | |
| + protocol: protocol, | |
| + pid: pid, | |
| + exe: exe, | |
| + services: services, | |
| + err: err, | |
| + clientAddr: clientAddr, | |
| + startTime: time.Now(), // Capture start time | |
| + }) | |
| + //} | |
| +} | |
| -func countRules(wl map[string][]RuleEntry) uint64 { | |
| - var total uint64 = 0 | |
| - for _, rs := range wl { | |
| - total += uint64(len(rs)) | |
| - } | |
| - return total | |
| +// SafeErr converts an error to a primitive string attribute safely. | |
| +// If the error is nil, it gracefully logs it as "<nil>" without panicking. | |
| +func SafeErr(err error) slog.Attr { | |
| + // if err == nil { | |
| + // return slog.String("err", "<nil>") | |
| + // } | |
| + // return SafeErr(err) | |
| + return SafeErr2("err", err) | |
| } | |
| -func isLowerASCII(s string) bool { | |
| - for i := 0; i < len(s); i++ { | |
| - c := s[i] | |
| - if c >= 'A' && c <= 'Z' { | |
| - return false | |
| - } | |
| +// SafeErr2 converts an error to a primitive string attribute safely. | |
| +// If the error is nil, it gracefully logs it as "<nil>" without panicking. | |
| +func SafeErr2(msg string, err error) slog.Attr { | |
| + if err == nil { | |
| + return slog.String(msg, "<nil>") | |
| } | |
| - return true | |
| + return slog.String(msg, err.Error()) | |
| } | |
| -func panic2(msg string) { | |
| - getBugLogger().Error(msg) | |
| - panic(msg) | |
| +// SafeAddr converts any net.Addr (UDP, TCP, IP, Unix, etc.) to a safe primitive string. | |
| +// It gracefully handles nil interface values and nil pointer implementations. | |
| +func SafeAddr(key string, addr net.Addr) slog.Attr { | |
| + // 1. Check if the interface itself is nil | |
| + // 2. Check if the underlying concrete pointer is nil using a type switch/assertion if needed, | |
| + // but a simple nil check against the interface covers standard uninitialized interface variables. | |
| + if addr == nil { | |
| + return slog.String(key, "<nil>") | |
| + } | |
| + | |
| + // net.Addr natively exposes the String() method, which evaluates instantly | |
| + return slog.String(key, addr.String()) | |
| } | |
| -// it's assumed that pattern and name are already lowercase(d) or uppercase(d), if not they won't match due to char case difference. | |
| -func matchPattern(pattern, name string) bool { | |
| - if !isLowerASCII(pattern) { | |
| - panic2("BUG: pattern was " + pattern + " which isn't lowercased, so bad coding somewhere!") | |
| +func (s *Server) handleUDP(ctx context.Context, wire []byte, clientAddr *net.UDPAddr, ln *net.UDPConn) { | |
| + log := s.getLogger() | |
| + | |
| + if clientAddr == nil { | |
| + panic2("BUG: nil ClientAddr in handleUDP, not possible?!") | |
| } | |
| - if !isLowerASCII(name) { | |
| - panic2("BUG: name was " + name + " which isn't lowercased, so bad coding somewhere!") | |
| + msg := new(dns.Msg) | |
| + if err := msg.Unpack(wire); err != nil { | |
| + // Edge: Invalid packet (common in floods) | |
| + log.Warn("invalid DNS UDP packet (couldn't Unpack) thus dropped/ignored", SafeErr(err)) | |
| + return | |
| + } | |
| + // 1. EXTRACT MAX UDP SIZE | |
| + // Default to standard 512 bytes, but check if the client provided an EDNS0 OPT record. | |
| + maxUDPSize := 512 | |
| + if clientOpt := msg.IsEdns0(); clientOpt != nil { | |
| + maxUDPSize = int(clientOpt.UDPSize()) | |
| } | |
| - // Fallback to recursive matching for other tokens ({*}, *, ?, !, literal text) | |
| - return recursiveMatch(pattern, name) | |
| -} | |
| + resp := s.handleDNSQuery(ctx, msg, clientAddr.String()) | |
| + if resp == nil { | |
| + cfg := s.getConfig() | |
| + log.Debug("Dropped UDP DNS response (is BlockMode 'drop' ?)", slog.String("BlockMode", cfg.BlockMode)) | |
| + return // BlockMode is "drop", so Drop | |
| + } | |
| -// recursiveMatch handles all tokens recursively. | |
| -func recursiveMatch(pattern, name string) bool { | |
| - for len(pattern) > 0 { | |
| - switch { | |
| - case strings.HasPrefix(pattern, "{**}"): | |
| - // consume 1+ chars including dots | |
| - pattern = pattern[4:] | |
| - if len(name) < 1 { | |
| - return false | |
| - } | |
| - for i := 1; i <= len(name); i++ { | |
| - if recursiveMatch(pattern, name[i:]) { | |
| - return true | |
| - } | |
| - } | |
| - return false | |
| + // 2. TRUNCATE IF NECESSARY | |
| + // If the response exceeds the client's max UDP size, miekg/dns will | |
| + // strip excess records and automatically set the TC (Truncated) bit. | |
| + resp.Truncate(maxUDPSize) | |
| + /* press Alt+z in vscode to see long lines wrapped, press again to get back ie. toggle. | |
| + Safe Fallbacks: If maxUDPSize happens to be set dangerously low by a broken client, miekg/dns's .Truncate() method internally enforces the RFC minimum of 512 bytes, so you don't have to worry about adding sanity checks for tiny bounds. | |
| + TCP Handoff: When a large response gets truncated, the client sees the TC bit flip to true. They will immediately drop the UDP response, open a new TCP connection to your server (which hits your handleTCP listener where the limit is 65k bytes), and get the full, untruncated response. | |
| + */ | |
| - case strings.HasPrefix(pattern, "**"): | |
| - // consume 0+ chars including dots | |
| - pattern = pattern[2:] | |
| - if len(name) == 0 { | |
| - return recursiveMatch(pattern, "") | |
| - } | |
| - for i := 0; i <= len(name); i++ { | |
| - if recursiveMatch(pattern, name[i:]) { | |
| - return true | |
| - } | |
| - } | |
| - return false | |
| + pack, err := resp.Pack() | |
| + if err != nil { | |
| + log.Warn("failed to pack DNS UDP packet response thus not sent", SafeErr(err)) | |
| + return | |
| + } | |
| + wroteN, err := ln.WriteToUDP(pack, clientAddr) | |
| + if err != nil { | |
| + log.Warn("failed to write to UDP the DNS packet response", SafeErr(err), slog.Int("wrote_bytes", wroteN), slog.Int("shoulda_written", len(pack))) | |
| + return | |
| + } | |
| +} | |
| - case strings.HasPrefix(pattern, "{*}"): | |
| - // consume 1+ chars, stop at dot | |
| - pattern = pattern[3:] | |
| - max3 := 0 | |
| - for j := 0; j < len(name) && name[j] != '.'; j++ { | |
| - max3 = j + 1 | |
| - } | |
| - if max3 < 1 { | |
| - return false | |
| - } | |
| - for i := 1; i <= max3; i++ { | |
| - if recursiveMatch(pattern, name[i:]) { | |
| - return true | |
| - } | |
| - } | |
| - return false | |
| +// is for Incoming Client Connections ie. send us a DNS Query via TCP port 53 | |
| +func (s *Server) handleTCP(ctx context.Context, conn net.Conn) { | |
| + cfg := s.getConfig() | |
| + log := s.getLogger() | |
| - case strings.HasPrefix(pattern, "*"): | |
| - // consume 0+ chars, stop at dot | |
| - pattern = pattern[1:] | |
| - if len(name) == 0 { | |
| - return recursiveMatch(pattern, "") | |
| - } | |
| - for i := 0; i <= len(name); i++ { | |
| - if i < len(name) && name[i] == '.' { | |
| - if recursiveMatch(pattern, name[i:]) { | |
| - return true | |
| - } | |
| - break | |
| - } | |
| - if recursiveMatch(pattern, name[i:]) { | |
| - return true | |
| - } | |
| - } | |
| - return false | |
| + defer conn.Close() //nolint:errcheck // best-effort close, nothing to do on error | |
| - case strings.HasPrefix(pattern, "?"): | |
| - // consume exactly 1 char, not dot | |
| - if len(name) == 0 || name[0] == '.' { | |
| - return false | |
| - } | |
| - pattern = pattern[1:] | |
| - name = name[1:] | |
| + var timeoutDuration time.Duration = time.Duration(cfg.ClientTCPTimeoutSec) * time.Second | |
| + const maxDNSTCPPacketSize = 65535 //nopeTODO: make this configurable in config.json ; It's the RFC 1035 hard limit (65535); not a tunable | |
| - case strings.HasPrefix(pattern, "!"): | |
| - // consume exactly 1 char, any | |
| - if len(name) == 0 { | |
| - return false | |
| - } | |
| - pattern = pattern[1:] | |
| - name = name[1:] | |
| - | |
| - default: | |
| - // literal char match | |
| - if len(name) == 0 || pattern[0] != name[0] { | |
| - return false | |
| - } | |
| - pattern = pattern[1:] | |
| - name = name[1:] | |
| - } | |
| + // --- 1. READ THE LENGTH HEADER --- | |
| + // We give the client 5 seconds to send just these 2 bytes. | |
| + if err1 := conn.SetReadDeadline(time.Now().Add(timeoutDuration)); err1 != nil { | |
| + log.Warn("failed to set read deadline for length header, thus dropped/ignored", SafeErr(err1), slog.Duration("deadline", timeoutDuration)) | |
| + return | |
| } | |
| - return len(name) == 0 | |
| -} | |
| - | |
| -// generate a cert that's valid for both local DoH listener and for webUI | |
| -func (s *Server) generateCertIfNeeded() { | |
| - log := s.getLogger() | |
| - cfg := s.getConfig() | |
| - | |
| - log.Debug("check if cert is valid or needs regen") | |
| - const certFile = "cert.pem" | |
| - const keyFile = "key.pem" | |
| - | |
| - needsRegen := false | |
| - | |
| - var err error | |
| - // Extract the host/IP from the DoH listener address | |
| - dohHost, _, err := net.SplitHostPort(cfg.ListenDoH) | |
| - if err != nil { | |
| - dohHost = cfg.ListenDoH | |
| + const TWO = 2 | |
| + buf := make([]byte, TWO) | |
| + if n, err := io.ReadFull(conn, buf); err != nil { | |
| + var netErr net.Error | |
| + //if netErr, ok := err.(net.Error); ok && netErr.Timeout() { | |
| + if errors.As(err, &netErr) && netErr.Timeout() { | |
| + log.Warn("DNS TCP: client connected but sent no data before deadline "+ | |
| + "(idle connection, port scanner, or client that opened then abandoned)", | |
| + SafeAddr("client", conn.RemoteAddr()), | |
| + slog.Duration("read_timeout", timeoutDuration), | |
| + ) | |
| + } else { | |
| + log.Warn("couldn't read 2 bytes from TCP DNS connection, thus dropped/ignored", | |
| + SafeErr(err), | |
| + slog.Int("read_bytes", n), | |
| + slog.Int("wanted_to_read_bytes", TWO), | |
| + slog.Duration("timeout", timeoutDuration), | |
| + ) | |
| + } | |
| + return | |
| } | |
| - // Extract the host/IP from the Web UI listener address | |
| - uiHost, _, err := net.SplitHostPort(cfg.ListenUI) | |
| - if err != nil { | |
| - uiHost = cfg.ListenUI | |
| + length := int(binary.BigEndian.Uint16(buf)) | |
| + if length > cfg.DoHMaxRequestBodyBytes || length == 0 { // Edge: Oversize packet | |
| + log.Warn("invalid packet length in TCP DNS connection, thus dropped/ignored", slog.Int("actual_bytes", length), slog.Int("max", maxDNSTCPPacketSize), | |
| + slog.Int("min", 1)) | |
| + return | |
| } | |
| - //In Go, net.ParseIP is a strict parser. It does not perform DNS lookups; it only checks if the string is a valid IPv4 or IPv6 literal. If you pass it "localhost", it returns nil. | |
| - | |
| - // STRICT IP ENFORCEMENT: Hostnames are strictly forbidden because | |
| - // they cannot be resolved before this local DNS proxy actually starts. | |
| - if net.ParseIP(dohHost) == nil { | |
| - panic2("BUG: config error: config.ListenDoH host part MUST be an IP literal. Hostnames are forbidden. Invalid value: " + dohHost) | |
| + // --- 2. READ THE BODY --- | |
| + // We REFRESH the deadline. The client gets a fresh 5 seconds | |
| + // to finish sending the actual DNS message. | |
| + //_ = conn.SetReadDeadline(time.Now().Add(timeoutDuration)) | |
| + if err1 := conn.SetReadDeadline(time.Now().Add(timeoutDuration)); err1 != nil { | |
| + log.Warn("failed to set read deadline for body, thus dropped/ignored", SafeErr(err1), slog.Duration("deadline", timeoutDuration)) | |
| + return | |
| } | |
| - | |
| - if net.ParseIP(uiHost) == nil { | |
| - panic2("BUG: config error: config.ListenUI host part MUST be an IP literal. Hostnames are forbidden. Invalid value: " + uiHost) | |
| + wire := make([]byte, length) | |
| + if n, err := io.ReadFull(conn, wire); err != nil { | |
| + log.Warn("couldn't read some bytes from TCP DNS connection, thus dropped/ignored", SafeErr(err), slog.Int("read_bytes", n), slog.Int("wanted_to_read_bytes", length), | |
| + slog.Duration("timeout", timeoutDuration)) | |
| + return | |
| } | |
| - // Build the list of requiredHosts/IPs that must be covered by the certificate | |
| - // Build the deduplicated required-hosts slice. | |
| - requiredHosts := []string{dohHost} | |
| - if cfg.WebUIUseTLS && uiHost != dohHost { | |
| - // WebUI host is only relevant when TLS is enabled for the WebUI. | |
| - // When WebUI runs plain HTTP, it never uses s.dohCert, so no SAN needed. | |
| - //also dedup | |
| - requiredHosts = append(requiredHosts, uiHost) | |
| + // --- 3. PROCESS --- | |
| + msg := new(dns.Msg) | |
| + if err := msg.Unpack(wire); err != nil { | |
| + log.Warn("invalid DNS TCP packet (couldn't Unpack) thus dropped/ignored", SafeErr(err)) | |
| + return | |
| } | |
| - // 2. Check if cert exists and is still valid for this IP | |
| - certBytes, err := os.ReadFile(certFile) | |
| - if err != nil { | |
| - // File missing or unreadable | |
| - log.Warn("Cert file doesn't exist", slog.String("file", certFile), SafeErr(err)) // no \n | |
| - needsRegen = true | |
| - } else { | |
| - // Parse the PEM | |
| - block, _ := pem.Decode(certBytes) | |
| - if block == nil { | |
| - log.Warn("Cert file had empty decoded block.", slog.String("file", certFile)) // no \n | |
| - needsRegen = true | |
| - } else { | |
| - cert, err2 := x509.ParseCertificate(block.Bytes) | |
| - if err2 != nil { | |
| - log.Warn("Cert file failed parsing", slog.String("file", certFile), SafeErr(err2)) // no \n | |
| - needsRegen = true | |
| - } else { | |
| - // Verify that ALL required hosts are present in the existing certificate's SAN list | |
| - for _, h := range requiredHosts { | |
| - found := false | |
| - parsedIP := net.ParseIP(h) | |
| - if parsedIP != nil { | |
| - // Check IP list | |
| - for _, ip := range cert.IPAddresses { | |
| - if ip.Equal(parsedIP) { | |
| - found = true | |
| - break | |
| - } | |
| - } | |
| - } else { | |
| - // Check DNS list | |
| - for _, name := range cert.DNSNames { | |
| - if name == h { | |
| - found = true | |
| - break | |
| - } | |
| - } | |
| - } | |
| - if !found { | |
| - log.Warn("Cert identity mismatch", slog.String("want", h), slog.Any("haveIPs", cert.IPAddresses), slog.Any("haveDNSNames", cert.DNSNames)) | |
| - needsRegen = true | |
| - break | |
| - } | |
| - } | |
| - } | |
| + resp := s.handleDNSQuery(ctx, msg, conn.RemoteAddr().String()) | |
| + // --- 4. WRITE THE RESPONSE --- | |
| + if resp != nil { | |
| + pack, err1 := resp.Pack() // Ignore err | |
| + if err1 != nil { | |
| + log.Warn("failed to pack DNS TCP packet response thus not sent", SafeErr(err1)) | |
| + return | |
| } | |
| - } | |
| + // Prepare the output (length + payload) | |
| + out := new(bytes.Buffer) | |
| + err2 := binary.Write(out, binary.BigEndian, uint16(len(pack))) // Single err return | |
| + if err2 != nil { | |
| + log.Warn("failed to write-to-the-buffer the pack len (2 bytes) of the TCP DNS packet response", SafeErr(err2)) | |
| + return | |
| + } | |
| + out.Write(pack) | |
| + // Set a WRITE deadline. This prevents a "slow receiver" from | |
| + // hanging your goroutine forever while you try to push data. | |
| + if err3 := conn.SetWriteDeadline(time.Now().Add(timeoutDuration)); err3 != nil { | |
| + log.Warn("failed to set write TCP deadline, thus dropped/ignored", SafeErr(err3), slog.Duration("deadline", timeoutDuration)) | |
| + return | |
| + } | |
| + wroteN, err4 := conn.Write(out.Bytes()) | |
| + if err4 != nil { | |
| + log.Warn("failed to write to TCP the DNS packet response body, thus dropped/ignored", SafeErr(err4), slog.Int("wrote_bytes", wroteN), | |
| + slog.Int("shoulda_written", len(pack)), slog.Duration("timeout", timeoutDuration)) | |
| + return | |
| + } | |
| + return | |
| + } // else it's nil like if BlockMode is "drop" | |
| + log.Debug("No TCP DNS response to write, likely due to BlockMode being 'drop' ?!") | |
| +} | |
| - // 3. Regen if necessary | |
| - if needsRegen { | |
| - log.Warn("Due to above, regenerating self-signed cert ...", slog.String("public_key_aka_cert_file", certFile), slog.String("private_key_file", keyFile), | |
| - slog.Any("hosts", requiredHosts)) | |
| - if err = s.generateCert(certFile, keyFile, requiredHosts); err != nil { | |
| - //done: need to unify logging errors in log and on console somehow, this printf and errorLogger thing is a mess. | |
| - s.logFatal("cert generation failed", err) | |
| - panic2("BUG: unreachable") | |
| +func getSecureID() uint16 { | |
| + b := make([]byte, 2) | |
| + maxRetries := 3 | |
| + | |
| + //for i := 0; i < maxRetries; i++ { | |
| + for i := range maxRetries { | |
| + //"Read is a helper function that calls Reader.Read using io.ReadFull. On return, n == len(b) if and only if err == nil." - Gemini 3 Thinking | |
| + _, err := rand.Read(b) | |
| + if err == nil { | |
| + //If err == nil, it is guaranteed that n is exactly the size of your buffer (2 bytes). | |
| + return binary.BigEndian.Uint16(b) | |
| } | |
| - s.certGeneration.Add(1) // <-- Increment here instead of returning true | |
| - // Build proper guidance message based on whether Web UI TLS is enabled | |
| - var msg strings.Builder | |
| - msg.WriteString("Cert generated: make sure you trust it in clients. ") | |
| - if cfg.WebUIUseTLS { | |
| - fmt.Fprintf(&msg, "For browsers, load the Web UI HTTPS URL: https://%s/ and add a certificate exception, or manually trust this endpoint via your browser's Certificate Manager. ", cfg.ListenUI) | |
| - } else { | |
| - fmt.Fprintf(&msg, "Web UI is configured with unencrypted HTTP: http://%s/ . ", cfg.ListenUI) | |
| + // Small sleep before retry to let system entropy recover | |
| + // Don't sleep on the very last attempt | |
| + if i < maxRetries-1 { | |
| + time.Sleep(10 * time.Millisecond) | |
| } | |
| - fmt.Fprintf(&msg, "For DoH clients, specify the server URL: https://%s/dns-query", cfg.ListenDoH) | |
| - log.Warn(msg.String(), | |
| - slog.String("doh_url", fmt.Sprintf("https://%s/dns-query", cfg.ListenDoH)), | |
| - slog.String(getJSONTagByOffset(unsafe.Offsetof(Config{}.ListenUI)), cfg.ListenUI)) | |
| - } else { | |
| - log.Debug("Existing cert is valid for host. Skipping generation.", slog.Any("hosts", requiredHosts)) | |
| } | |
| - // Load cert/key into global for reuse | |
| - log.Info("Loading cert/key for DoH and Web UI...") | |
| + // If we get here, the OS is fundamentally broken. | |
| + // It's safer to crash than to serve insecure/predictable DNS. | |
| + // If we reach this point, the system CSPRNG is failing. | |
| + // Panic is the safest security choice for a DNS proxy. | |
| + panic2("BUG: critical system error: failed to generate secure random entropy") | |
| + panic(nil) | |
| +} | |
| - s.dohCert, err = tls.LoadX509KeyPair(certFile, keyFile) | |
| - if err != nil { | |
| - s.logFatal("cert_load_failed", err) | |
| - panic2("BUG: unreachable") | |
| - } | |
| - log.Info("Success - loaded into tls.Certificate") | |
| +type CacheEntry struct { | |
| + Msg *dns.Msg | |
| + State UpstreamState | |
| } | |
| -// 'host' can be localhost or 127.0.0.1 for example, but it won't be looked up! | |
| -func (s *Server) generateCert(certFileNameNoPath, keyFileNameNoPath string, hosts []string) error { | |
| +func (s *Server) dohHandler(w http.ResponseWriter, r *http.Request) { | |
| + cfg := s.getConfig() | |
| log := s.getLogger() | |
| - if certFileNameNoPath == "" || keyFileNameNoPath == "" { | |
| - panic2("BUG: unexpected empty filename(s) for cert,key: '" + certFileNameNoPath + "','" + keyFileNameNoPath + "'") | |
| - } | |
| - if len(hosts) == 0 { | |
| - panic2("BUG: generateCert: hosts slice is empty — nothing to put in the SAN") | |
| + | |
| + ctx := r.Context() // Get the request context | |
| + | |
| + var err error | |
| + // IP verification before resolving | |
| + remoteHost, _, splitErr := net.SplitHostPort(r.RemoteAddr) | |
| + if splitErr != nil { | |
| + remoteHost = r.RemoteAddr | |
| } | |
| - certFileNameNoPath = filepath.Clean(certFileNameNoPath) | |
| - keyFileNameNoPath = filepath.Clean(keyFileNameNoPath) | |
| - // From crypto/tls/generate_cert.go; edge: Ensure unique serial, valid for 10y | |
| - priv, err := rsa.GenerateKey(rand.Reader, 2048) | |
| - if err != nil { | |
| - return fmt.Errorf("key gen failed: %w", err) | |
| + if net.ParseIP(remoteHost) == nil { | |
| + panic2("BUG: dohHandler: net.ResolveTCPAddr requires an IP. r.RemoteAddr is not a valid IP: " + r.RemoteAddr) | |
| } | |
| - serial := big.NewInt(0) | |
| - // Strip hyphens so it's a valid hex string | |
| - hexUUID := strings.ReplaceAll(uuid.New().String(), "-", "") | |
| - serial.SetString(hexUUID, 16) // Unique serial | |
| - certTemplate := x509.Certificate{ | |
| - SerialNumber: serial, | |
| - Subject: pkix.Name{ | |
| - Organization: []string{"DNSbollocks ie. Local DNS Proxy"}, | |
| - }, | |
| - NotBefore: time.Now(), | |
| - NotAfter: time.Now().Add(365 * 24 * time.Hour * 10), | |
| - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, | |
| - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, | |
| - BasicConstraintsValid: true, //"Explicitly write the Basic Constraints extension into the certificate metadata, and mark IsCA as false(or as it's set below)." | |
| - IsCA: false, // Defaults to false if not specified | |
| + | |
| + // 1. Identify the client immediately, before replying. | |
| + //Since you are performing the PID lookup inside the handler (before sending the response), the TCP connection is guaranteed to be in the ESTABLISHED state. | |
| + // Firefox is sitting there waiting for its DNS-over-HTTPS answer, so it's the perfect time to "catch" it in the Windows TCP table. | |
| + remoteTCP, err := net.ResolveTCPAddr("tcp", r.RemoteAddr) | |
| + if err == nil { | |
| + log.Debug("client connected(early logging)", | |
| + slog.String("proto", "DoH"), | |
| + //slog.String("clientAddr", remoteTCP.String()), | |
| + SafeAddr("clientAddr", remoteTCP), | |
| + ) | |
| + // Use our TCP PID helper | |
| + pid, exe, pErr := wincoe.PidAndExeForTCP(remoteTCP) | |
| + // wincoe.Smashy() | |
| + ctx = s.makeClientInfoContext(ctx, "DoH", remoteTCP, pid, exe, pErr) | |
| + } else { | |
| + log.Warn("DoH: could not resolve remote addr", slog.String("addr", r.RemoteAddr)) | |
| + //FIXME: this is a bigger problem than a WARN, if it happens! but an ERROR here would make it mix with the red colored blocked requests, thus harder to be seen! | |
| + //TODO: see if we can trigger this! and/or think of what happens if it happens! | |
| } | |
| - // Populate IPAddresses and DNSNames dynamically for all requested hosts | |
| - // Deduplicate hosts before adding to SAN to avoid malformed certs. | |
| - seenIPs := make(map[string]struct{}) | |
| - seenDNS := make(map[string]struct{}) | |
| - for _, host := range hosts { | |
| - host = strings.TrimSpace(host) | |
| - if host == "" { | |
| - continue | |
| - } | |
| - if ip := net.ParseIP(host); ip != nil { | |
| - key := ip.String() // normalise e.g. "::1" vs "0:0:0:0:0:0:0:1" | |
| - if _, dup := seenIPs[key]; !dup { | |
| - seenIPs[key] = struct{}{} | |
| - certTemplate.IPAddresses = append(certTemplate.IPAddresses, ip) | |
| - } | |
| - } else { | |
| - if _, dup := seenDNS[host]; !dup { | |
| - seenDNS[host] = struct{}{} | |
| - certTemplate.DNSNames = append(certTemplate.DNSNames, host) | |
| - } | |
| - } | |
| + if r.Method != http.MethodPost && r.Method != http.MethodGet { //"POST" "GET" | |
| + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |
| + return | |
| + } | |
| + var body []byte | |
| + if r.Method == http.MethodPost { //"POST" { | |
| + // Limit incoming DoH payload to 64KB to prevent memory exhaustion attacks | |
| + r.Body = http.MaxBytesReader(w, r.Body, int64(cfg.DoHMaxRequestBodyBytes)) | |
| + body, err = io.ReadAll(r.Body) | |
| + } else { | |
| + encoded := r.URL.Query().Get("dns") | |
| + body, err = base64.RawURLEncoding.DecodeString(encoded) | |
| + if err != nil { | |
| + http.Error(w, "Invalid GET param", http.StatusBadRequest) | |
| + return | |
| + } | |
| } | |
| - if len(certTemplate.IPAddresses) == 0 && len(certTemplate.DNSNames) == 0 { | |
| - // All hosts were empty or whitespace after trimming — programmer error. | |
| - panic2(fmt.Sprintf("BUG: generateCert: no valid SANs could be built from hosts %v", hosts)) | |
| + if err != nil || len(body) == 0 { | |
| + http.Error(w, "Bad request", http.StatusBadRequest) | |
| + return | |
| } | |
| - | |
| - derBytes, err := x509.CreateCertificate(rand.Reader, &certTemplate, &certTemplate, &priv.PublicKey, priv) | |
| + msg := new(dns.Msg) | |
| + if err2 := msg.Unpack(body); err2 != nil { | |
| + http.Error(w, fmt.Sprintf("Failed to unpack DNS query, err:%v", err2), http.StatusBadRequest) | |
| + return | |
| + } | |
| + resp := s.handleDNSQuery(ctx, msg, r.RemoteAddr /*field not method*/) | |
| + if resp == nil { //can happen when BlockMode is "drop" so nvmFIXME? "For DoH the HTTP connection is already accepted; 503 is the only correct response for drop mode" | |
| + log.Warn("empty DNS response, replying to client with service unavailable", slog.String("client", r.RemoteAddr)) | |
| + w.WriteHeader(http.StatusServiceUnavailable) | |
| + return | |
| + } | |
| + pack, err := resp.Pack() | |
| if err != nil { | |
| - return fmt.Errorf("cert create failed: %w", err) | |
| + log.Warn("doh_pack_response_to_client_failed", SafeErr(err), slog.String("client", r.RemoteAddr)) | |
| + // Return a 500 error to the DoH client | |
| + http.Error(w, "Failed to pack DNS response", http.StatusInternalServerError) | |
| + return | |
| } | |
| - // not this way: #nosec G304 | |
| - certOut, err := os.Create(certFileNameNoPath) | |
| + w.Header().Set("Content-Type", "application/dns-message") | |
| + w.Header().Set("Content-Length", fmt.Sprint(len(pack))) | |
| + w.WriteHeader(http.StatusOK) | |
| + wroteN, err := w.Write(pack) | |
| if err != nil { | |
| - return fmt.Errorf("cert write failed: %w", err) | |
| - } else { | |
| - defer func() { | |
| - if closeErr := certOut.Close(); closeErr != nil { | |
| - log.Error("failed to close cert public key file (incompletely written to disk then?)", SafeErr(closeErr), slog.String("filename", certFileNameNoPath)) | |
| - } | |
| - }() | |
| + log.Warn("failed to write the DoH reply to client (the DNS packet response body)", SafeErr(err), slog.Int("wrote_bytes", wroteN), slog.Int("shoulda_written", len(pack))) | |
| + return | |
| } | |
| - if err = pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil { | |
| - return fmt.Errorf("pem encode cert failed: %w", err) | |
| +} | |
| + | |
| +func (s *Server) handleDNSQuery(ctx context.Context, msg *dns.Msg, clientAddr string) *dns.Msg { | |
| + cfg := s.getConfig() | |
| + log := s.getLogger() | |
| + //This is the important one — without capturing it once, a reload landing between the cachee-hit check and a later Set for the same request could write into a freshly-swapped (empty) cachee generation while having read from the old one. | |
| + cachee := s.getCache() | |
| + | |
| + if len(msg.Question) != 1 { | |
| + return formerrResponse(msg) | |
| + } | |
| + q := msg.Question[0] | |
| + domain := strings.ToLower(strings.TrimSuffix(q.Name, ".")) //XXX: must lowercase it for matchPattern below! at least. | |
| + if domain == "" || !isValidDNSName(domain) { // Edge: Empty domain | |
| + return formerrResponse(msg) | |
| } | |
| + qtype := dns.TypeToString[q.Qtype] // Map lookup | |
| - //keyOut, err := os.Create(keyFile) | |
| - // 2. Fix the Key Permissions: Replace os.Create(keyFile) with this: | |
| - // not this way: #nosec G304 but this way filepath.Clean( | |
| - keyOut, err := os.OpenFile(keyFileNameNoPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) | |
| - if err != nil { | |
| - return fmt.Errorf("key write failed: %w", err) | |
| - } else { | |
| - defer func() { | |
| - if closeErr := keyOut.Close(); closeErr != nil { | |
| - log.Error("failed to close cert private key file (incompletely written to disk then?)", SafeErr(closeErr), slog.String("filename", keyFileNameNoPath)) | |
| - } | |
| - }() | |
| + // Rate limit | |
| + if allowed, reason := s.rateLimiter.Allow(clientAddr); !allowed { | |
| + log.Warn(reason, slog.String("client", clientAddr)) | |
| + sfr := servfailResponse(msg) | |
| + s.logQuery(ctx, clientAddr, domain, qtype, reason, "", nil, sfr, UpstreamState{Strategy: "rateLimited"}) | |
| + return sfr | |
| } | |
| - if err := pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}); err != nil { | |
| - return fmt.Errorf("pem encode key failed: %w", err) | |
| + | |
| + matchedID, matched := s.ruleStore.MatchForType(qtype, domain) | |
| + if !matched && cfg.AllowHTTPSIfAAllowed && qtype == "HTTPS" { | |
| + matchedID, matched = s.ruleStore.MatchForType("A", domain) | |
| } | |
| - return nil | |
| -} | |
| -type contextKey string | |
| + if !matched { | |
| + s.stats.Add(1) | |
| + s.recentBlocks.Record(domain, qtype, cfg.MaxRecentBlocks) | |
| + blocked := s.blockResponse(msg) | |
| + s.logQuery(ctx, clientAddr, domain, qtype, blockedSTR, "", nil, blocked, UpstreamState{Strategy: "blockedByLackOfRuleAllowingIt"}) | |
| + return blocked | |
| + } | |
| -const clientInfoKey contextKey = "clientInfo" | |
| + // Cache (edge: Negative responses cached short) | |
| + key := domain + ":" + qtype | |
| -type clientMetadata struct { | |
| - protocol string | |
| - pid uint32 | |
| - exe string | |
| - services []string | |
| - err error | |
| - clientAddr net.Addr | |
| - startTime time.Time | |
| -} | |
| + //fmt.Printf("checking '%s' key in cache\n", key) | |
| + if entry, ok := cachee.Get(key); ok { | |
| + //entry := cachedIf.(CacheEntry) | |
| + cached := entry.Msg | |
| -// Listeners... | |
| + // Return a copy of cached response with the current query ID to avoid | |
| + // clients rejecting replies because of mismatched transaction IDs. | |
| + resp := cached.Copy() | |
| + resp.Id = msg.Id | |
| + //fmt.Printf("found '%s' key in cache as: '%s' aka %+v aka %#v\n", key, resp.String(), resp, resp) | |
| + ips := extractIPs(resp) | |
| -func (s *Server) makeClientInfoContext(ctx context.Context, protocol string, clientAddr net.Addr, pid uint32, exe string, err error) context.Context { | |
| - log := s.getLogger() | |
| + // Use the stored upstreamState4, but update the strategy to indicate it was loaded from cache | |
| + upstreamState4 := entry.State | |
| + //state.Strategy = "cached (was: " + state.Strategy + ")" | |
| - var services []string | |
| - var serviceInfo string | |
| - if err != nil { | |
| - log.Warn("couldn't get pid and exe name", | |
| - slog.String("proto", protocol), | |
| - //slog.String("clientAddr", clientAddr.String()), | |
| - SafeAddr("clientAddr", clientAddr), | |
| - SafeErr(err)) | |
| - //services = []string{"<err:no_pid>"} | |
| - //return ctx | |
| - serviceInfo = "err:no_pid" | |
| - } else { | |
| - //fmt.Println("!before") | |
| - //services, err := wincoe.GetServiceNamesFromPIDCached(pid) // this epic shadowing with no warnings! (golangci-lint v2 is broken when using v1.27 devel Go) and vscode didn't say anything on its own. | |
| - services, err = wincoe.GetServiceNamesFromPIDCached(pid) | |
| - //services = []string{"<service-lookup-disabled-for-debug>"} | |
| - //fmt.Println("!after") | |
| - if err != nil { | |
| - serviceInfo = fmt.Sprintf("err=%v", err) | |
| - } else { | |
| - serviceInfo = fmt.Sprintf("%v", services) | |
| - // if len(services) > 0 { | |
| - // serviceInfo = fmt.Sprintf("%d services: %v", len(services), services) | |
| - // } else { | |
| - // serviceInfo = "no services" | |
| - // } | |
| - } | |
| + s.logQuery(ctx, clientAddr, domain, qtype, cacheHit, matchedID, ips, resp, upstreamState4) | |
| + return resp | |
| } | |
| - log.Debug("client connected", | |
| - slog.String("proto", protocol), | |
| - //slog.String("clientAddr", clientAddr.String()), | |
| - SafeAddr("clientAddr", clientAddr), | |
| - slog.Int64("pid", int64(pid)), | |
| - slog.String("exe", exe), | |
| - slog.String("services", serviceInfo), | |
| - SafeErr(err), | |
| - ) | |
| + // --- START Local Hosts Override --- | |
| + // Local hosts check (was: s.localHostsMu.RLock / range s.localHosts) | |
| + if matchedIPs, ok := s.hostStore.Match(domain); ok { | |
| + resp := new(dns.Msg) | |
| + resp.SetReply(msg) | |
| + resp.Authoritative = true | |
| + resp.RecursionAvailable = true | |
| - // Create a specific context for THIS packet | |
| - return context.WithValue(ctx, clientInfoKey, clientMetadata{ | |
| - protocol: protocol, | |
| - pid: pid, | |
| - exe: exe, | |
| - services: services, | |
| - err: err, | |
| - clientAddr: clientAddr, | |
| - startTime: time.Now(), // Capture start time | |
| - }) | |
| - //} | |
| -} | |
| + for _, ip := range matchedIPs { | |
| + isIPv4 := ip.To4() != nil | |
| -// SafeErr converts an error to a primitive string attribute safely. | |
| -// If the error is nil, it gracefully logs it as "<nil>" without panicking. | |
| -func SafeErr(err error) slog.Attr { | |
| - // if err == nil { | |
| - // return slog.String("err", "<nil>") | |
| - // } | |
| - // return SafeErr(err) | |
| - return SafeErr2("err", err) | |
| -} | |
| + if qtype == "A" && isIPv4 { | |
| + rr := new(dns.A) | |
| + rr.Hdr = dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: cfg.LocalHostsOverrideTTLSec} | |
| + rr.A = ip | |
| + resp.Answer = append(resp.Answer, rr) | |
| + } else if qtype == "AAAA" && !isIPv4 { | |
| + rr := new(dns.AAAA) | |
| + rr.Hdr = dns.RR_Header{Name: q.Name, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: cfg.LocalHostsOverrideTTLSec} | |
| + rr.AAAA = ip | |
| + resp.Answer = append(resp.Answer, rr) | |
| + } | |
| + } | |
| -// SafeErr2 converts an error to a primitive string attribute safely. | |
| -// If the error is nil, it gracefully logs it as "<nil>" without panicking. | |
| -func SafeErr2(msg string, err error) slog.Attr { | |
| - if err == nil { | |
| - return slog.String(msg, "<nil>") | |
| - } | |
| - return slog.String(msg, err.Error()) | |
| -} | |
| + // Cache this override so subsequent queries bypass the pattern loop | |
| + upstreamState5 := UpstreamState{Strategy: "etc_hosts"} | |
| + cachee.Set(key, CacheEntry{ | |
| + Msg: resp.Copy(), | |
| + State: upstreamState5, | |
| + }, time.Duration(cfg.LocalHostsOverrideTTLSec)*time.Second) //doneTODO: configurable cache time and dns record aka ttl time? (see above) | |
| -// SafeAddr converts any net.Addr (UDP, TCP, IP, Unix, etc.) to a safe primitive string. | |
| -// It gracefully handles nil interface values and nil pointer implementations. | |
| -func SafeAddr(key string, addr net.Addr) slog.Attr { | |
| - // 1. Check if the interface itself is nil | |
| - // 2. Check if the underlying concrete pointer is nil using a type switch/assertion if needed, | |
| - // but a simple nil check against the interface covers standard uninitialized interface variables. | |
| - if addr == nil { | |
| - return slog.String(key, "<nil>") | |
| + s.logQuery(ctx, clientAddr, domain, qtype, localHostOverride, "", extractIPs(resp), resp, upstreamState5) | |
| + return resp | |
| } | |
| + // --- END Local Hosts Override --- | |
| - // net.Addr natively exposes the String() method, which evaluates instantly | |
| - return slog.String(key, addr.String()) | |
| -} | |
| - | |
| -func (s *Server) handleUDP(ctx context.Context, wire []byte, clientAddr *net.UDPAddr, ln *net.UDPConn) { | |
| - log := s.getLogger() | |
| - | |
| - if clientAddr == nil { | |
| - panic2("BUG: nil ClientAddr in handleUDP, not possible?!") | |
| - } | |
| - msg := new(dns.Msg) | |
| - if err := msg.Unpack(wire); err != nil { | |
| - // Edge: Invalid packet (common in floods) | |
| - log.Warn("invalid DNS UDP packet (couldn't Unpack) thus dropped/ignored", SafeErr(err)) | |
| - return | |
| + // Forward to upstream DNS | |
| + // 1. Save the original client ID | |
| + oldID := msg.Id | |
| + msg.Id = getSecureID() // 2. Generate a random ID for the upstream query (helps prevent cache poisoning) | |
| + // 3. DO THE ACTUAL UPSTREAM QUERY | |
| + resp, upstreamState3 := s.dohForwarder.ForwardToDoH(ctx, msg) | |
| + // 4. Restore the original ID so the client's DNS resolver accepts the answer | |
| + msg.Id = oldID // unconditionally restore so any msg-derived error response carries the right ID | |
| + if resp != nil { | |
| + resp.Id = oldID // Restores the ID for the upstream's response object | |
| } | |
| - // 1. EXTRACT MAX UDP SIZE | |
| - // Default to standard 512 bytes, but check if the client provided an EDNS0 OPT record. | |
| - maxUDPSize := 512 | |
| - if clientOpt := msg.IsEdns0(); clientOpt != nil { | |
| - maxUDPSize = int(clientOpt.UDPSize()) | |
| + //Gemini 3 Thinking: "The ID Matching is a "defense in depth" move. By using a random ID for the journey to Quad9 and back, you decouple your internal network's IDs from the public internet, | |
| + // making it much harder for someone to inject fake DNS responses into your proxy." | |
| + if resp == nil || resp.Rcode != dns.RcodeSuccess { | |
| + ips := []string{} //{"NXDOMAIN"} | |
| + if resp != nil { | |
| + ips = append(ips, fmt.Sprintf("dns.Rcode:%d", resp.Rcode)) | |
| + } | |
| + negResp := servfailResponse(msg) | |
| + s.logQuery(ctx, clientAddr, domain, qtype, forwardedButFailedSoSERVFAIL, matchedID, ips, negResp, upstreamState3) | |
| + // Cache negatives short | |
| + // Store a copy of the negative response as well | |
| + cachee.Set(key, CacheEntry{ | |
| + Msg: negResp.Copy(), | |
| + State: upstreamState3, | |
| + }, time.Duration(cfg.CacheNegativeTTLSec)*time.Second) // time to cache negatives | |
| + return negResp | |
| } | |
| - resp := s.handleDNSQuery(ctx, msg, clientAddr.String()) | |
| - if resp == nil { | |
| - cfg := s.getConfig() | |
| - log.Debug("Dropped UDP DNS response (is BlockMode 'drop' ?)", slog.String("BlockMode", cfg.BlockMode)) | |
| - return // BlockMode is "drop", so Drop | |
| + //ips := extractIPs(resp) //before 'resp' gets mutated, and its IPs deleted. | |
| + // Use a copy of the original upstream response so we can log exactly what they tried to send | |
| + originalCopy := resp.Copy() | |
| + originalIPs := extractIPs(originalCopy) | |
| + // Filter | |
| + filtered, filterReason := filterResponse(log, resp, cfg.RemoveHTTPSIPv4Hints, getJSONTagByOffset(unsafe.Offsetof(Config{}.RemoveHTTPSIPv4Hints)), s.blacklist) // XXX: resp gets mutated here! | |
| + if filtered == nil { | |
| + // filterReason now holds exact info like "blockedByUpstream_ZeroIP" or "dns_rebinding_protection" | |
| + | |
| + s.logQuery(ctx, clientAddr, domain, qtype, | |
| + filterReason+originalSTR, //blockedByUpstream_ORIGINAL //doneFIXME: this here is a guess because the upstream answer was filtered out likely due to having an IP like 0.0.0.0 returned, but could also be any of the blocked IPs specified in the config like 127.0.0.1/8 or 192.168.0.0/16 therefore this could mean the upstream tried to return a local or LAN IP but we stripped it out and we should notify accordingly! not just say that upstream blocked the hostname request which it only does if IP was 0.0.0.0 and nothing else. | |
| + matchedID, originalIPs, originalCopy, upstreamState3) | |
| + blocked := s.blockResponse(msg) | |
| + blockedIPs := extractIPs(blocked) | |
| + s.logQuery(ctx, clientAddr, domain, qtype, | |
| + filterReason+returnedModifiedSTR, //doneFIXME: this here is a guess because the upstream answer was filtered out likely due to having an IP like 0.0.0.0 returned, but could also be any of the blocked IPs specified in the config like 127.0.0.1/8 or 192.168.0.0/16 therefore this could mean the upstream tried to return a local or LAN IP but we stripped it out and we should notify accordingly! not just say that upstream blocked the hostname request which it only does if IP was 0.0.0.0 and nothing else. | |
| + matchedID, blockedIPs, blocked, upstreamState3) | |
| + return blocked | |
| } | |
| - // 2. TRUNCATE IF NECESSARY | |
| - // If the response exceeds the client's max UDP size, miekg/dns will | |
| - // strip excess records and automatically set the TC (Truncated) bit. | |
| - resp.Truncate(maxUDPSize) | |
| - /* press Alt+z in vscode to see long lines wrapped, press again to get back ie. toggle. | |
| - Safe Fallbacks: If maxUDPSize happens to be set dangerously low by a broken client, miekg/dns's .Truncate() method internally enforces the RFC minimum of 512 bytes, so you don't have to worry about adding sanity checks for tiny bounds. | |
| - TCP Handoff: When a large response gets truncated, the client sees the TC bit flip to true. They will immediately drop the UDP response, open a new TCP connection to your server (which hits your handleTCP listener where the limit is 65k bytes), and get the full, untruncated response. | |
| - */ | |
| + // Cache with clamped TTL | |
| + expiry := max(computeTTLForCaching(filtered), time.Duration(cfg.CacheMinTTL)*time.Second) | |
| - pack, err := resp.Pack() | |
| - if err != nil { | |
| - log.Warn("failed to pack DNS UDP packet response thus not sent", SafeErr(err)) | |
| - return | |
| - } | |
| - wroteN, err := ln.WriteToUDP(pack, clientAddr) | |
| - if err != nil { | |
| - log.Warn("failed to write to UDP the DNS packet response", SafeErr(err), slog.Int("wrote_bytes", wroteN), slog.Int("shoulda_written", len(pack))) | |
| - return | |
| - } | |
| -} | |
| + // Store a copy in the cache, not the pointer you are about to return | |
| + //cacheStore.Set(key, filtered.Copy(), expiry) | |
| + cachee.Set(key, CacheEntry{ | |
| + Msg: filtered.Copy(), | |
| + State: upstreamState3, | |
| + }, expiry) | |
| -// is for Incoming Client Connections ie. send us a DNS Query via TCP port 53 | |
| -func (s *Server) handleTCP(ctx context.Context, conn net.Conn) { | |
| - cfg := s.getConfig() | |
| - log := s.getLogger() | |
| + ips := extractIPs(filtered) | |
| + s.logQuery(ctx, clientAddr, domain, qtype, forwardedSTR, matchedID, ips, filtered, upstreamState3) | |
| - defer conn.Close() //nolint:errcheck // best-effort close, nothing to do on error | |
| + return filtered | |
| +} | |
| - var timeoutDuration time.Duration = time.Duration(cfg.ClientTCPTimeoutSec) * time.Second | |
| - const maxDNSTCPPacketSize = 65535 //nopeTODO: make this configurable in config.json ; It's the RFC 1035 hard limit (65535); not a tunable | |
| +func computeTTLForCaching(msg *dns.Msg) time.Duration { | |
| + //To correctly handle upstream negative caching responses (like NXDOMAIN or NODATA), we need to check both the Answer section and the Ns (Authority) section. Additionally, if an SOA (Start of Authority) record is found in the Authority section, RFC 2308 mandates that the negative cache TTL should be capped by the SOA's Minttl value. | |
| + var minTTL uint32 = 3600 // Default 1 hour, not: //86400 // 24 hours | |
| - // --- 1. READ THE LENGTH HEADER --- | |
| - // We give the client 5 seconds to send just these 2 bytes. | |
| - if err1 := conn.SetReadDeadline(time.Now().Add(timeoutDuration)); err1 != nil { | |
| - log.Warn("failed to set read deadline for length header, thus dropped/ignored", SafeErr(err1), slog.Duration("deadline", timeoutDuration)) | |
| - return | |
| - } | |
| + found := false | |
| - const TWO = 2 | |
| - buf := make([]byte, TWO) | |
| - if n, err := io.ReadFull(conn, buf); err != nil { | |
| - var netErr net.Error | |
| - //if netErr, ok := err.(net.Error); ok && netErr.Timeout() { | |
| - if errors.As(err, &netErr) && netErr.Timeout() { | |
| - log.Warn("DNS TCP: client connected but sent no data before deadline "+ | |
| - "(idle connection, port scanner, or client that opened then abandoned)", | |
| - SafeAddr("client", conn.RemoteAddr()), | |
| - slog.Duration("read_timeout", timeoutDuration), | |
| - ) | |
| - } else { | |
| - log.Warn("couldn't read 2 bytes from TCP DNS connection, thus dropped/ignored", | |
| - SafeErr(err), | |
| - slog.Int("read_bytes", n), | |
| - slog.Int("wanted_to_read_bytes", TWO), | |
| - slog.Duration("timeout", timeoutDuration), | |
| - ) | |
| + // 1. Check the Answer section | |
| + for _, rr := range msg.Answer { | |
| + found = true | |
| + if rr.Header().Ttl < minTTL { | |
| + minTTL = rr.Header().Ttl | |
| } | |
| - return | |
| } | |
| - length := int(binary.BigEndian.Uint16(buf)) | |
| - if length > cfg.DoHMaxRequestBodyBytes || length == 0 { // Edge: Oversize packet | |
| - log.Warn("invalid packet length in TCP DNS connection, thus dropped/ignored", slog.Int("actual_bytes", length), slog.Int("max", maxDNSTCPPacketSize), | |
| - slog.Int("min", 1)) | |
| - return | |
| + // 2. Check the Authority (Ns) section for negative responses (e.g., SOA) | |
| + for _, rr := range msg.Ns { | |
| + found = true | |
| + if rr.Header().Ttl < minTTL { | |
| + minTTL = rr.Header().Ttl | |
| + } | |
| + // RFC 2308: For negative caching, use the minimum of the SOA TTL and its MinTTL field | |
| + if soa, ok := rr.(*dns.SOA); ok { | |
| + if soa.Minttl < minTTL { | |
| + minTTL = soa.Minttl | |
| + } | |
| + } | |
| } | |
| - // --- 2. READ THE BODY --- | |
| - // We REFRESH the deadline. The client gets a fresh 5 seconds | |
| - // to finish sending the actual DNS message. | |
| - //_ = conn.SetReadDeadline(time.Now().Add(timeoutDuration)) | |
| - if err1 := conn.SetReadDeadline(time.Now().Add(timeoutDuration)); err1 != nil { | |
| - log.Warn("failed to set read deadline for body, thus dropped/ignored", SafeErr(err1), slog.Duration("deadline", timeoutDuration)) | |
| - return | |
| + if !found { | |
| + minTTL = defaultCacheMinTTL // * time.Second | |
| } | |
| - wire := make([]byte, length) | |
| - if n, err := io.ReadFull(conn, wire); err != nil { | |
| - log.Warn("couldn't read some bytes from TCP DNS connection, thus dropped/ignored", SafeErr(err), slog.Int("read_bytes", n), slog.Int("wanted_to_read_bytes", length), | |
| - slog.Duration("timeout", timeoutDuration)) | |
| - return | |
| + if minTTL < cacheMinTTLClamp { //XXX: hardcoded minimum aka floor of 10 seconds TTL | |
| + minTTL = cacheMinTTLClamp | |
| } | |
| + return time.Duration(minTTL) * time.Second | |
| +} | |
| - // --- 3. PROCESS --- | |
| - msg := new(dns.Msg) | |
| - if err := msg.Unpack(wire); err != nil { | |
| - log.Warn("invalid DNS TCP packet (couldn't Unpack) thus dropped/ignored", SafeErr(err)) | |
| - return | |
| - } | |
| +// Version is a global variable that can be overwritten at build time like this: go build -ldflags="-X 'dnsbollocks.Version=$(git describe --tags --always)'" -o dnsbollocks.exe | |
| +var Version = "" | |
| - resp := s.handleDNSQuery(ctx, msg, conn.RemoteAddr().String()) | |
| - // --- 4. WRITE THE RESPONSE --- | |
| - if resp != nil { | |
| - pack, err1 := resp.Pack() // Ignore err | |
| - if err1 != nil { | |
| - log.Warn("failed to pack DNS TCP packet response thus not sent", SafeErr(err1)) | |
| - return | |
| - } | |
| - // Prepare the output (length + payload) | |
| - out := new(bytes.Buffer) | |
| - err2 := binary.Write(out, binary.BigEndian, uint16(len(pack))) // Single err return | |
| - if err2 != nil { | |
| - log.Warn("failed to write-to-the-buffer the pack len (2 bytes) of the TCP DNS packet response", SafeErr(err2)) | |
| - return | |
| +// Compute the string exactly once at package startup | |
| +var memoizedVersion = func() string { | |
| + var baseVersion string | |
| + var vcsRevision string | |
| + var vcsTime string // the datetime of that commit(aka vcsRevision) not the build datetime! | |
| + var isModified bool //ie. dirty | |
| + | |
| + // 1. Determine the base version (Release tag / module path) | |
| + if Version != "" { | |
| + baseVersion = Version | |
| + } else if info, ok := debug.ReadBuildInfo(); ok { | |
| + if info.Main.Version != "" && info.Main.Version != "(devel)" { | |
| + baseVersion = info.Main.Version | |
| } | |
| - out.Write(pack) | |
| - // Set a WRITE deadline. This prevents a "slow receiver" from | |
| - // hanging your goroutine forever while you try to push data. | |
| - if err3 := conn.SetWriteDeadline(time.Now().Add(timeoutDuration)); err3 != nil { | |
| - log.Warn("failed to set write TCP deadline, thus dropped/ignored", SafeErr(err3), slog.Duration("deadline", timeoutDuration)) | |
| - return | |
| - } | |
| - wroteN, err4 := conn.Write(out.Bytes()) | |
| - if err4 != nil { | |
| - log.Warn("failed to write to TCP the DNS packet response body, thus dropped/ignored", SafeErr(err4), slog.Int("wrote_bytes", wroteN), | |
| - slog.Int("shoulda_written", len(pack)), slog.Duration("timeout", timeoutDuration)) | |
| - return | |
| - } | |
| - return | |
| - } // else it's nil like if BlockMode is "drop" | |
| - log.Debug("No TCP DNS response to write, likely due to BlockMode being 'drop' ?!") | |
| -} | |
| - | |
| -func getSecureID() uint16 { | |
| - b := make([]byte, 2) | |
| - maxRetries := 3 | |
| - | |
| - //for i := 0; i < maxRetries; i++ { | |
| - for i := range maxRetries { | |
| - //"Read is a helper function that calls Reader.Read using io.ReadFull. On return, n == len(b) if and only if err == nil." - Gemini 3 Thinking | |
| - _, err := rand.Read(b) | |
| - if err == nil { | |
| - //If err == nil, it is guaranteed that n is exactly the size of your buffer (2 bytes). | |
| - return binary.BigEndian.Uint16(b) | |
| - } | |
| - // Small sleep before retry to let system entropy recover | |
| - // Don't sleep on the very last attempt | |
| - if i < maxRetries-1 { | |
| - time.Sleep(10 * time.Millisecond) | |
| - } | |
| - } | |
| - | |
| - // If we get here, the OS is fundamentally broken. | |
| - // It's safer to crash than to serve insecure/predictable DNS. | |
| - // If we reach this point, the system CSPRNG is failing. | |
| - // Panic is the safest security choice for a DNS proxy. | |
| - panic2("BUG: critical system error: failed to generate secure random entropy") | |
| - panic(nil) | |
| -} | |
| - | |
| -type CacheEntry struct { | |
| - Msg *dns.Msg | |
| - State UpstreamState | |
| -} | |
| - | |
| -func (s *Server) dohHandler(w http.ResponseWriter, r *http.Request) { | |
| - cfg := s.getConfig() | |
| - log := s.getLogger() | |
| - | |
| - ctx := r.Context() // Get the request context | |
| - | |
| - var err error | |
| - // IP verification before resolving | |
| - remoteHost, _, splitErr := net.SplitHostPort(r.RemoteAddr) | |
| - if splitErr != nil { | |
| - remoteHost = r.RemoteAddr | |
| - } | |
| - if net.ParseIP(remoteHost) == nil { | |
| - panic2("BUG: dohHandler: net.ResolveTCPAddr requires an IP. r.RemoteAddr is not a valid IP: " + r.RemoteAddr) | |
| - } | |
| - | |
| - // 1. Identify the client immediately, before replying. | |
| - //Since you are performing the PID lookup inside the handler (before sending the response), the TCP connection is guaranteed to be in the ESTABLISHED state. | |
| - // Firefox is sitting there waiting for its DNS-over-HTTPS answer, so it's the perfect time to "catch" it in the Windows TCP table. | |
| - remoteTCP, err := net.ResolveTCPAddr("tcp", r.RemoteAddr) | |
| - if err == nil { | |
| - log.Debug("client connected(early logging)", | |
| - slog.String("proto", "DoH"), | |
| - //slog.String("clientAddr", remoteTCP.String()), | |
| - SafeAddr("clientAddr", remoteTCP), | |
| - ) | |
| - // Use our TCP PID helper | |
| - pid, exe, pErr := wincoe.PidAndExeForTCP(remoteTCP) | |
| - // wincoe.Smashy() | |
| - ctx = s.makeClientInfoContext(ctx, "DoH", remoteTCP, pid, exe, pErr) | |
| - } else { | |
| - log.Warn("DoH: could not resolve remote addr", slog.String("addr", r.RemoteAddr)) | |
| - //FIXME: this is a bigger problem than a WARN, if it happens! but an ERROR here would make it mix with the red colored blocked requests, thus harder to be seen! | |
| - //TODO: see if we can trigger this! and/or think of what happens if it happens! | |
| } | |
| - if r.Method != http.MethodPost && r.Method != http.MethodGet { //"POST" "GET" | |
| - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |
| - return | |
| + // Default base if nothing is found yet | |
| + if baseVersion == "" { | |
| + baseVersion = "dev" | |
| } | |
| - var body []byte | |
| - if r.Method == http.MethodPost { //"POST" { | |
| - // Limit incoming DoH payload to 64KB to prevent memory exhaustion attacks | |
| - r.Body = http.MaxBytesReader(w, r.Body, int64(cfg.DoHMaxRequestBodyBytes)) | |
| - body, err = io.ReadAll(r.Body) | |
| - } else { | |
| - encoded := r.URL.Query().Get("dns") | |
| - body, err = base64.RawURLEncoding.DecodeString(encoded) | |
| - if err != nil { | |
| - http.Error(w, "Invalid GET param", http.StatusBadRequest) | |
| - return | |
| + // 2. Extract the underlying VCS revision if embedded by the compiler | |
| + if info, ok := debug.ReadBuildInfo(); ok { | |
| + for _, setting := range info.Settings { | |
| + switch setting.Key { | |
| + case "vcs.revision": | |
| + if setting.Value != "" { | |
| + vcsRevision = setting.Value | |
| + // Cap to roughly 16 characters for clean visibility | |
| + if len(vcsRevision) > 16 { | |
| + vcsRevision = vcsRevision[:16] | |
| + } | |
| + } | |
| + case "vcs.time": | |
| + if setting.Value != "" { | |
| + // Parse standard RFC3339 layout: "2026-06-20T20:49:57Z" | |
| + if t, err := time.Parse(time.RFC3339, setting.Value); err == nil { | |
| + // // Formats to a compact, human-readable slug: "20260620.204957" | |
| + // vcsTime = t.Format("20060102.150405") | |
| + // Formats to exact pseudo-version layout: "20260620204957" | |
| + vcsTime = t.Format("20060102150405") | |
| + } else { | |
| + // // Fallback if parsing fails for some unexpected compiler reason | |
| + // vcsTime = strings.ReplaceAll(setting.Value, ":", "") | |
| + // Clean fallback if parsing fails | |
| + vcsTime = strings.NewReplacer("-", "", "T", "", ":", "", "Z", "").Replace(setting.Value) | |
| + } | |
| + } | |
| + case "vcs.modified": | |
| + if setting.Value == "true" { | |
| + isModified = true | |
| + } | |
| + } | |
| } | |
| } | |
| - if err != nil || len(body) == 0 { | |
| - http.Error(w, "Bad request", http.StatusBadRequest) | |
| - return | |
| - } | |
| - msg := new(dns.Msg) | |
| - if err2 := msg.Unpack(body); err2 != nil { | |
| - http.Error(w, fmt.Sprintf("Failed to unpack DNS query, err:%v", err2), http.StatusBadRequest) | |
| - return | |
| + | |
| + // 3. Assemble the final version string idiomatically | |
| + suffix := "" | |
| + //like this/(via `go version -m dnsbollocks.exe`): dep github.com/miekg/dns v1.1.73-0.20260402044838-d1539a788a12 | |
| + if vcsTime != "" { | |
| + suffix += "-0." + vcsTime // cantFIXME: Hardcodes the '0' generation counter before the timestamp (can't read/get it apparently) "Go's debug.ReadBuildInfo doesn't expose it; nothing to do" - Claude Sonnet 4.6 Low Thinking | |
| } | |
| - resp := s.handleDNSQuery(ctx, msg, r.RemoteAddr /*field not method*/) | |
| - if resp == nil { //can happen when BlockMode is "drop" so nvmFIXME? "For DoH the HTTP connection is already accepted; 503 is the only correct response for drop mode" | |
| - log.Warn("empty DNS response, replying to client with service unavailable", slog.String("client", r.RemoteAddr)) | |
| - w.WriteHeader(http.StatusServiceUnavailable) | |
| - return | |
| + // Avoid duplicating the hash if the base version string already includes it | |
| + if vcsRevision != "" && !strings.Contains(baseVersion, vcsRevision) { | |
| + suffix += "-" + vcsRevision | |
| } | |
| - pack, err := resp.Pack() | |
| - if err != nil { | |
| - log.Warn("doh_pack_response_to_client_failed", SafeErr(err), slog.String("client", r.RemoteAddr)) | |
| - // Return a 500 error to the DoH client | |
| - http.Error(w, "Failed to pack DNS response", http.StatusInternalServerError) | |
| - return | |
| + if isModified { | |
| + suffix += "+dirty" | |
| } | |
| - w.Header().Set("Content-Type", "application/dns-message") | |
| - w.Header().Set("Content-Length", fmt.Sprint(len(pack))) | |
| - w.WriteHeader(http.StatusOK) | |
| - wroteN, err := w.Write(pack) | |
| - if err != nil { | |
| - log.Warn("failed to write the DoH reply to client (the DNS packet response body)", SafeErr(err), slog.Int("wrote_bytes", wroteN), slog.Int("shoulda_written", len(pack))) | |
| - return | |
| - } | |
| + return baseVersion + suffix | |
| +}() //it's a func call | |
| + | |
| +// GetVersion returns the cached build info string directly | |
| +func GetVersion() string { | |
| + return memoizedVersion | |
| } | |
| -func (s *Server) handleDNSQuery(ctx context.Context, msg *dns.Msg, clientAddr string) *dns.Msg { | |
| - cfg := s.getConfig() | |
| - log := s.getLogger() | |
| - //This is the important one — without capturing it once, a reload landing between the cachee-hit check and a later Set for the same request could write into a freshly-swapped (empty) cachee generation while having read from the old one. | |
| - cachee := s.getCache() | |
| +type UpstreamState struct { //doneTODO: rename Telemetry to something normal | |
| + Strategy string `json:"strategy"` | |
| + UpstreamUsed string `json:"upstream_used"` | |
| + FailedUpstreams []string `json:"failed_upstreams"` | |
| +} | |
| - if len(msg.Question) != 1 { | |
| - return formerrResponse(msg) | |
| - } | |
| - q := msg.Question[0] | |
| - domain := strings.ToLower(strings.TrimSuffix(q.Name, ".")) //XXX: must lowercase it for matchPattern below! at least. | |
| - if domain == "" || !isValidDNSName(domain) { // Edge: Empty domain | |
| - return formerrResponse(msg) | |
| +// SafeRequestAttr extracts only the essential primitive data fields from an http.Request | |
| +// into a race-safe, highly readable slog.Attr group without using reflection(ie. slog.Any). | |
| +func SafeRequestAttr(key string, req *http.Request) slog.Attr { | |
| + if req == nil { | |
| + return slog.Group(key) | |
| } | |
| - qtype := dns.TypeToString[q.Qtype] // Map lookup | |
| - // Rate limit | |
| - if allowed, reason := s.rateLimiter.Allow(clientAddr); !allowed { | |
| - log.Warn(reason, slog.String("client", clientAddr)) | |
| - sfr := servfailResponse(msg) | |
| - s.logQuery(ctx, clientAddr, domain, qtype, reason, "", nil, sfr, UpstreamState{Strategy: "rateLimited"}) | |
| - return sfr | |
| - } | |
| + return slog.Group(key, | |
| + slog.String("method", req.Method), | |
| + slog.String("url", req.URL.String()), | |
| + slog.String("proto", req.Proto), | |
| + slog.String("host", req.Host), | |
| + slog.String("content_type", req.Header.Get("Content-Type")), | |
| + ) | |
| +} | |
| - matchedID, matched := s.ruleStore.MatchForType(qtype, domain) | |
| - if !matched && cfg.AllowHTTPSIfAAllowed && qtype == "HTTPS" { | |
| - matchedID, matched = s.ruleStore.MatchForType("A", domain) | |
| - } | |
| +func (u *Upstream) doSingleDoHRequest(ctx context.Context, reqBytes []byte) (*dns.Msg, error) { | |
| + log := u.getLogger() | |
| - if !matched { | |
| - s.stats.Add(1) | |
| - s.recentBlocks.Record(domain, qtype, cfg.MaxRecentBlocks) | |
| - blocked := s.blockResponse(msg) | |
| - s.logQuery(ctx, clientAddr, domain, qtype, blockedSTR, "", nil, blocked, UpstreamState{Strategy: "blockedByLackOfRuleAllowingIt"}) | |
| - return blocked | |
| + if u.Client == nil { | |
| + panic2(fmt.Sprintf("BUG: dev fail: dohClient is still nil at calling doSingleDoHRequest! shouldn't happen! upstreamURL=%s SNI=%s", u.URL, u.SNI)) | |
| } | |
| - // Cache (edge: Negative responses cached short) | |
| - key := domain + ":" + qtype | |
| + retries := u.Retries //cfg.UpstreamRetriesPerQuery | |
| + if retries < 1 { | |
| + retries = 0 // Sanity check: must attempt at least once(see the 'for' below) | |
| + } | |
| + maxTries := 1 + retries | |
| - //fmt.Printf("checking '%s' key in cache\n", key) | |
| - if entry, ok := cachee.Get(key); ok { | |
| - //entry := cachedIf.(CacheEntry) | |
| - cached := entry.Msg | |
| + var resp *http.Response | |
| + var err4ClientDo error | |
| + var req *http.Request | |
| + var cancelCurrentReq func() // Track the active context cancel function across scopes | |
| - // Return a copy of cached response with the current query ID to avoid | |
| - // clients rejecting replies because of mismatched transaction IDs. | |
| - resp := cached.Copy() | |
| - resp.Id = msg.Id | |
| - //fmt.Printf("found '%s' key in cache as: '%s' aka %+v aka %#v\n", key, resp.String(), resp, resp) | |
| - ips := extractIPs(resp) | |
| + //for attempt := range maxTries { // starts from 0 ! | |
| + for attempt := 1; attempt <= maxTries; attempt++ { | |
| + // Use an anonymous function wrapper so 'defer' operates on a per-iteration scope | |
| + failedToCreateRequest, errReq := func() (bool, error) { | |
| + // 1. Create a transient request context derived from the client's ctx | |
| + // reqCtx, cancelReq := context.WithCancel(ctx) | |
| + //NOTTRUEXXX: when the upstream IP is set to Deny in portmaster firewall after it worked before, without this context.WithTimeout it will hang forever until Ctrl+C cancels context then you see all the logs that show it was stuck. This is the only way. | |
| + // 1. Derive a timed-out context from your incoming request context (reqCtx) | |
| + reqCtx, cancelReq := context.WithTimeout(ctx, u.UpstreamClientTimeoutDuration) | |
| + // Crucial: always defer cancel to prevent context leaks! | |
| + // defer cancel() NO | |
| - // Use the stored upstreamState4, but update the strategy to indicate it was loaded from cache | |
| - upstreamState4 := entry.State | |
| - //state.Strategy = "cached (was: " + state.Strategy + ")" | |
| + // If the server shuts down while this request is in-flight, cancel it. | |
| + // stopWatch() frees the AfterFunc's internal resources once we no longer need it. | |
| + stopWatch := context.AfterFunc(u.BackgroundCtx, cancelReq) | |
| - s.logQuery(ctx, clientAddr, domain, qtype, cacheHit, matchedID, ips, resp, upstreamState4) | |
| - return resp | |
| - } | |
| + // Use a flag to track if responsibility for calling cancelReq() has been handed off | |
| + var handedOver bool | |
| + defer func() { | |
| + if !handedOver { | |
| + stopWatch() // prevent AfterFunc from firing; safe no-op if already fired | |
| + cancelReq() // Clean up immediately on panic or retryable error | |
| + } | |
| + }() | |
| - // --- START Local Hosts Override --- | |
| - // Local hosts check (was: s.localHostsMu.RLock / range s.localHosts) | |
| - if matchedIPs, ok := s.hostStore.Match(domain); ok { | |
| - resp := new(dns.Msg) | |
| - resp.SetReply(msg) | |
| - resp.Authoritative = true | |
| - resp.RecursionAvailable = true | |
| + // // 2. Spin up a quick monitor to cancel the request if the application shuts down | |
| + // go func() { | |
| + // select { | |
| + // case <-u.BackgroundCtx.Done(): //this must be Server.ctx or s.ctx former backgroundCtx | |
| + // cancelReq() // Aborts the HTTP request immediately on Ctrl+C | |
| + // case <-reqCtx.Done(): | |
| + // // Normal exit when the request finishes or client disconnects | |
| + // } | |
| + // }() | |
| - for _, ip := range matchedIPs { | |
| - isIPv4 := ip.To4() != nil | |
| + // 3. Pass the merged context to the HTTP request | |
| - if qtype == "A" && isIPv4 { | |
| - rr := new(dns.A) | |
| - rr.Hdr = dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: cfg.LocalHostsOverrideTTLSec} | |
| - rr.A = ip | |
| - resp.Answer = append(resp.Answer, rr) | |
| - } else if qtype == "AAAA" && !isIPv4 { | |
| - rr := new(dns.AAAA) | |
| - rr.Hdr = dns.RR_Header{Name: q.Name, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: cfg.LocalHostsOverrideTTLSec} | |
| - rr.AAAA = ip | |
| - resp.Answer = append(resp.Answer, rr) | |
| + // create request with supplied context so caller controls deadline/cancel | |
| + var e error | |
| + req, e = http.NewRequestWithContext(reqCtx, http.MethodPost /*"POST"*/, u.URL.String(), bytes.NewReader(reqBytes)) | |
| + if e != nil { | |
| + // Wrap the error to give it context and satisfy wrapcheck | |
| + return true, fmt.Errorf("failed to create DoH HTTP request: %w", e /*non-nil*/) | |
| } | |
| - } | |
| - // Cache this override so subsequent queries bypass the pattern loop | |
| - upstreamState5 := UpstreamState{Strategy: "etc_hosts"} | |
| - cachee.Set(key, CacheEntry{ | |
| - Msg: resp.Copy(), | |
| - State: upstreamState5, | |
| - }, time.Duration(cfg.LocalHostsOverrideTTLSec)*time.Second) //doneTODO: configurable cache time and dns record aka ttl time? (see above) | |
| + req.Header.Set("Content-Type", "application/dns-message") | |
| + if u.SNI != "" { | |
| + req.Host = u.SNI | |
| + } | |
| - s.logQuery(ctx, clientAddr, domain, qtype, localHostOverride, "", extractIPs(resp), resp, upstreamState5) | |
| - return resp | |
| - } | |
| - // --- END Local Hosts Override --- | |
| + log2 := u.getLogger() | |
| + log2.Debug("Attempting request to upstream", slog.String("url", u.URL.String()), slog.String("sni", u.SNI)) | |
| + // Capture the HTTP client execution | |
| + resp, err4ClientDo = u.Client.Do(req) // this is concurrency safe | |
| + if err4ClientDo == nil { | |
| + //success | |
| + //cancelCurrentReq = cancelReq // Hand off the cancellation function to the outer scope | |
| + // Hand ownership to outer scope. stopWatch must also be called there | |
| + // to free AfterFunc resources once the response body is consumed. | |
| + cancelCurrentReq = func() { | |
| + stopWatch() | |
| + cancelReq() | |
| + } | |
| + handedOver = true // Detach this iteration's deferred cleanup | |
| + } | |
| + return false, nil | |
| + }() | |
| - // Forward to upstream DNS | |
| - // 1. Save the original client ID | |
| - oldID := msg.Id | |
| - msg.Id = getSecureID() // 2. Generate a random ID for the upstream query (helps prevent cache poisoning) | |
| - // 3. DO THE ACTUAL UPSTREAM QUERY | |
| - resp, upstreamState3 := s.dohForwarder.ForwardToDoH(ctx, msg) | |
| - // 4. Restore the original ID so the client's DNS resolver accepts the answer | |
| - msg.Id = oldID // unconditionally restore so any msg-derived error response carries the right ID | |
| - if resp != nil { | |
| - resp.Id = oldID // Restores the ID for the upstream's response object | |
| - } | |
| - //Gemini 3 Thinking: "The ID Matching is a "defense in depth" move. By using a random ID for the journey to Quad9 and back, you decouple your internal network's IDs from the public internet, | |
| - // making it much harder for someone to inject fake DNS responses into your proxy." | |
| - if resp == nil || resp.Rcode != dns.RcodeSuccess { | |
| - ips := []string{} //{"NXDOMAIN"} | |
| - if resp != nil { | |
| - ips = append(ips, fmt.Sprintf("dns.Rcode:%d", resp.Rcode)) | |
| + // If NewRequestWithContext failed, abort immediately just like the original logic | |
| + if failedToCreateRequest { | |
| + return nil, errReq | |
| } | |
| - negResp := servfailResponse(msg) | |
| - s.logQuery(ctx, clientAddr, domain, qtype, forwardedButFailedSoSERVFAIL, matchedID, ips, negResp, upstreamState3) | |
| - // Cache negatives short | |
| - // Store a copy of the negative response as well | |
| - cachee.Set(key, CacheEntry{ | |
| - Msg: negResp.Copy(), | |
| - State: upstreamState3, | |
| - }, time.Duration(cfg.CacheNegativeTTLSec)*time.Second) // time to cache negatives | |
| - return negResp | |
| - } | |
| - //ips := extractIPs(resp) //before 'resp' gets mutated, and its IPs deleted. | |
| - // Use a copy of the original upstream response so we can log exactly what they tried to send | |
| - originalCopy := resp.Copy() | |
| - originalIPs := extractIPs(originalCopy) | |
| - // Filter | |
| - filtered, filterReason := filterResponse(log, resp, cfg.RemoveHTTPSIPv4Hints, getJSONTagByOffset(unsafe.Offsetof(Config{}.RemoveHTTPSIPv4Hints)), s.blacklist) // XXX: resp gets mutated here! | |
| - if filtered == nil { | |
| - // filterReason now holds exact info like "blockedByUpstream_ZeroIP" or "dns_rebinding_protection" | |
| + // If client.Do succeeded, we can stop retrying | |
| + if err4ClientDo == nil { //XXX: if you change or move this, the logic below changes drastically! be careful | |
| + //success! | |
| + break | |
| + } | |
| - s.logQuery(ctx, clientAddr, domain, qtype, | |
| - filterReason+originalSTR, //blockedByUpstream_ORIGINAL //doneFIXME: this here is a guess because the upstream answer was filtered out likely due to having an IP like 0.0.0.0 returned, but could also be any of the blocked IPs specified in the config like 127.0.0.1/8 or 192.168.0.0/16 therefore this could mean the upstream tried to return a local or LAN IP but we stripped it out and we should notify accordingly! not just say that upstream blocked the hostname request which it only does if IP was 0.0.0.0 and nothing else. | |
| - matchedID, originalIPs, originalCopy, upstreamState3) | |
| - blocked := s.blockResponse(msg) | |
| - blockedIPs := extractIPs(blocked) | |
| - s.logQuery(ctx, clientAddr, domain, qtype, | |
| - filterReason+returnedModifiedSTR, //doneFIXME: this here is a guess because the upstream answer was filtered out likely due to having an IP like 0.0.0.0 returned, but could also be any of the blocked IPs specified in the config like 127.0.0.1/8 or 192.168.0.0/16 therefore this could mean the upstream tried to return a local or LAN IP but we stripped it out and we should notify accordingly! not just say that upstream blocked the hostname request which it only does if IP was 0.0.0.0 and nothing else. | |
| - matchedID, blockedIPs, blocked, upstreamState3) | |
| - return blocked | |
| - } | |
| + //so we're here because the request error-ed | |
| - // Cache with clamped TTL | |
| - expiry := max(computeTTLForCaching(filtered), time.Duration(cfg.CacheMinTTL)*time.Second) | |
| + // decide if error is transient/retryable | |
| + // common retryable errors: temporary network errors, EOF, connection reset | |
| + var netErr net.Error | |
| + isRetryable := errors.Is(err4ClientDo, io.EOF) || errors.Is(err4ClientDo, io.ErrUnexpectedEOF) || | |
| + errors.Is(err4ClientDo, syscall.ECONNRESET) || // Since you are on Windows, syscall.ECONNRESET is actually mapped to the Windows-specific WSAECONNRESET code internally by the Go net package, so errors.Is will work correctly across platforms if you ever decide to compile this for Linux/macOS too. | |
| + errors.Is(err4ClientDo, syscall.ECONNREFUSED) || | |
| + (errors.As(err4ClientDo, &netErr) && netErr.Timeout()) //netErr.Timeout(): This is the "official" way to check for timeouts now. It covers both the network dial timing out and your http.Client.Timeout. | |
| - // Store a copy in the cache, not the pointer you are about to return | |
| - //cacheStore.Set(key, filtered.Copy(), expiry) | |
| - cachee.Set(key, CacheEntry{ | |
| - Msg: filtered.Copy(), | |
| - State: upstreamState3, | |
| - }, expiry) | |
| + if isRetryable { | |
| + log.Error("doh_post_transient_error for this query", SafeErr(err4ClientDo), | |
| + slog.Int("current_try", attempt), slog.Int("max_tries", maxTries), | |
| + //slog.Any("query", req), | |
| + SafeRequestAttr("query", req), | |
| + slog.Bool("will_retry", attempt < maxTries)) | |
| - ips := extractIPs(filtered) | |
| - s.logQuery(ctx, clientAddr, domain, qtype, forwardedSTR, matchedID, ips, filtered, upstreamState3) | |
| + // 🔴 FIX #1: If this was the last attempt, return the REAL error immediately! | |
| + // This prevents falling through to the bottom of the function. | |
| + if attempt >= maxTries { | |
| + return nil, fmt.Errorf("exhausted %d/%d tries to upstream DoH, last request's err: %w", attempt, maxTries, err4ClientDo /*non-nil here*/) | |
| + } | |
| + if u.RetryBackoffDuration <= 0 { | |
| + u.RetryBackoffDuration = time.Duration(100) * time.Millisecond | |
| + log.Warn("BUG: retry backoff timer is set to <= 0 , preventing hang by using 100ms", slog.Duration("retrybackoff_duration", u.RetryBackoffDuration)) | |
| + } else if u.RetryBackoffDuration >= time.Duration(5)*time.Second { | |
| + log.Warn("RetryBackoffDuration is >= 5 sec", slog.Duration("retrybackoff_duration", u.RetryBackoffDuration)) | |
| + } | |
| + // small backoff: sleep a bit but respect context | |
| + select { | |
| + case <-time.After(u.RetryBackoffDuration): | |
| + log.Debug("Retrying after backoff", SafeRequestAttr("query", req), slog.Duration("retrybackoff_duration", u.RetryBackoffDuration)) | |
| + //exits select | |
| + case <-ctx.Done(): | |
| + log.Debug("doh sensed client quit during retry backoff...") | |
| + return nil, fmt.Errorf("doh sensed client quit during retry backoff... ctx.err: %w", ctx.Err() /*non-nil guaranteed*/) | |
| + case <-u.BackgroundCtx.Done(): | |
| + log.Debug("doh sensed quit during retry backoff...") | |
| + return nil, fmt.Errorf("doh sensed quit during retry backoff... bkgctx.err: %w", u.BackgroundCtx.Err() /*non-nil guaranteed*/) | |
| + } | |
| + continue //next try | |
| + } | |
| + // non-retryable error | |
| + // --- NEW DIAGNOSTIC BLOCK --- | |
| + if strings.Contains(err4ClientDo.Error(), "tls:") || strings.Contains(err4ClientDo.Error(), "x509:") { | |
| + log.Error("TLS verification failed when tried to query upstream DNS server", | |
| + slog.String("url", u.URL.String()), | |
| + slog.String("sni_used", u.SNI), | |
| + SafeErr(err4ClientDo)) | |
| - return filtered | |
| -} | |
| + // Run a manual probe to see what the server is actually sending | |
| + u.logCertDetails() //targetURL.Hostname(), targetURL.Port(), sni) | |
| + } else { | |
| + log.Error("Failed to query upstream DNS server", | |
| + slog.String("url", u.URL.String()), | |
| + slog.String("sni_used", u.SNI), | |
| + SafeErr(err4ClientDo)) | |
| + } | |
| + // --- END DIAGNOSTIC BLOCK --- | |
| + return nil, fmt.Errorf("failed to send the HTTP request to the upstream DoH server %q, err: %w", u.URL.String(), err4ClientDo /*non-nil here*/) | |
| + } //for retries | |
| -func computeTTLForCaching(msg *dns.Msg) time.Duration { | |
| - //To correctly handle upstream negative caching responses (like NXDOMAIN or NODATA), we need to check both the Answer section and the Ns (Authority) section. Additionally, if an SOA (Start of Authority) record is found in the Authority section, RFC 2308 mandates that the negative cache TTL should be capped by the SOA's Minttl value. | |
| - var minTTL uint32 = 3600 // Default 1 hour, not: //86400 // 24 hours | |
| + // --- THE CODE BELOW ONLY EXECUTES ON SUCCESSFUL BREAK --- | |
| - found := false | |
| + // ✅ Ensure the active context gets cancelled when the outer function returns | |
| + if cancelCurrentReq != nil { | |
| + defer cancelCurrentReq() | |
| + } | |
| - // 1. Check the Answer section | |
| - for _, rr := range msg.Answer { | |
| - found = true | |
| - if rr.Header().Ttl < minTTL { | |
| - minTTL = rr.Header().Ttl | |
| - } | |
| + if resp == nil { | |
| + // last attempt produced no response (shouldn't happen), treat as failure | |
| + log.Error("doh_no_response") | |
| + return nil, errors.New("no response") | |
| + } else { | |
| + defer resp.Body.Close() //nolint:errcheck // best-effort close, nothing to do on error | |
| } | |
| - // 2. Check the Authority (Ns) section for negative responses (e.g., SOA) | |
| - for _, rr := range msg.Ns { | |
| - found = true | |
| - if rr.Header().Ttl < minTTL { | |
| - minTTL = rr.Header().Ttl | |
| - } | |
| - // RFC 2308: For negative caching, use the minimum of the SOA TTL and its MinTTL field | |
| - if soa, ok := rr.(*dns.SOA); ok { | |
| - if soa.Minttl < minTTL { | |
| - minTTL = soa.Minttl | |
| - } | |
| - } | |
| + // ✅ This will now execute perfectly! The context is guaranteed to stay alive here. | |
| + body, err4ReadAll := io.ReadAll(resp.Body) | |
| + if err4ReadAll != nil { | |
| + log.Error("doh_readbody_failed", SafeErr(err4ReadAll)) | |
| + return nil, fmt.Errorf("failed to read upstream DoH response body: %w", err4ReadAll /*non-nil here*/) | |
| } | |
| - if !found { | |
| - minTTL = defaultCacheMinTTL // * time.Second | |
| + // debug/log non-200 or unexpected content-type | |
| + if resp.StatusCode != 200 { | |
| + log.Error("doh_upstream_status", slog.String("status", resp.Status)) | |
| + return nil, fmt.Errorf("upstream status %s", resp.Status) | |
| } | |
| - if minTTL < cacheMinTTLClamp { //XXX: hardcoded minimum aka floor of 10 seconds TTL | |
| - minTTL = cacheMinTTLClamp | |
| + ct := resp.Header.Get("Content-Type") | |
| + if ct != "application/dns-message" { | |
| + log.Error("doh_upstream_content_type isn't the expected application/dns-message", slog.String("content_type", ct)) | |
| + } | |
| + if len(body) < 12 { | |
| + log.Error("doh_upstream_body_too_short", slog.Int("len", len(body))) | |
| } | |
| - return time.Duration(minTTL) * time.Second | |
| -} | |
| -// Version is a global variable that can be overwritten at build time like this: go build -ldflags="-X 'dnsbollocks.Version=$(git describe --tags --always)'" -o dnsbollocks.exe | |
| -var Version = "" | |
| + upMsg := new(dns.Msg) | |
| + if err4Unpack := upMsg.Unpack(body); err4Unpack != nil { | |
| + n := len(body) | |
| + log.Error("doh_unpack_failed", SafeErr(err4Unpack), | |
| + slog.String("body_hex", fmt.Sprintf("Upstream body (hex, first %d): %x", n, body[:n])), | |
| + slog.String("body_text", fmt.Sprintf("Upstream body (text, first %d): %q", n, body[:n])), | |
| + ) | |
| + return nil, fmt.Errorf("failed to unpack response body for upstream DoH %q, err: %w", u.URL.String(), err4Unpack /*non-nil here*/) | |
| + } | |
| + return upMsg, nil | |
| +} | |
| -// Compute the string exactly once at package startup | |
| -var memoizedVersion = func() string { | |
| - var baseVersion string | |
| - var vcsRevision string | |
| - var vcsTime string // the datetime of that commit(aka vcsRevision) not the build datetime! | |
| - var isModified bool //ie. dirty | |
| +func (u *Upstream) logCertDetails() { //(ip, port, sni string) { | |
| + log := u.getLogger() | |
| - // 1. Determine the base version (Release tag / module path) | |
| - if Version != "" { | |
| - baseVersion = Version | |
| - } else if info, ok := debug.ReadBuildInfo(); ok { | |
| - if info.Main.Version != "" && info.Main.Version != "(devel)" { | |
| - baseVersion = info.Main.Version | |
| - } | |
| + port := u.URL.Port() | |
| + if port == "" { | |
| + //TODO: replace all panics with logFatal() ? | |
| + panic2("BUG: dev fail: port is empty but shoulda been set in validateUpstream() to 443") | |
| } | |
| + addr := net.JoinHostPort(u.URL.Hostname(), port) | |
| - // Default base if nothing is found yet | |
| - if baseVersion == "" { | |
| - baseVersion = "dev" | |
| + dialer := &net.Dialer{Timeout: time.Duration(u.CertLogTimeoutSec) * time.Second} | |
| + // XXX: We use InsecureSkipVerify: true ONLY for this probe so we can read the cert | |
| + // that was otherwise rejected. | |
| + conn, err := tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{ | |
| + ServerName: u.SNI, | |
| + InsecureSkipVerify: true, | |
| + }) | |
| + | |
| + if err != nil { | |
| + log.Error("Diagnostic probe failed", slog.String("addr", addr), SafeErr(err)) | |
| + return | |
| + } else { | |
| + defer conn.Close() //nolint:errcheck // best-effort close, nothing to do on error | |
| } | |
| - // 2. Extract the underlying VCS revision if embedded by the compiler | |
| - if info, ok := debug.ReadBuildInfo(); ok { | |
| - for _, setting := range info.Settings { | |
| - switch setting.Key { | |
| - case "vcs.revision": | |
| - if setting.Value != "" { | |
| - vcsRevision = setting.Value | |
| - // Cap to roughly 16 characters for clean visibility | |
| - if len(vcsRevision) > 16 { | |
| - vcsRevision = vcsRevision[:16] | |
| - } | |
| - } | |
| - case "vcs.time": | |
| - if setting.Value != "" { | |
| - // Parse standard RFC3339 layout: "2026-06-20T20:49:57Z" | |
| - if t, err := time.Parse(time.RFC3339, setting.Value); err == nil { | |
| - // // Formats to a compact, human-readable slug: "20260620.204957" | |
| - // vcsTime = t.Format("20060102.150405") | |
| - // Formats to exact pseudo-version layout: "20260620204957" | |
| - vcsTime = t.Format("20060102150405") | |
| - } else { | |
| - // // Fallback if parsing fails for some unexpected compiler reason | |
| - // vcsTime = strings.ReplaceAll(setting.Value, ":", "") | |
| - // Clean fallback if parsing fails | |
| - vcsTime = strings.NewReplacer("-", "", "T", "", ":", "", "Z", "").Replace(setting.Value) | |
| - } | |
| - } | |
| - case "vcs.modified": | |
| - if setting.Value == "true" { | |
| - isModified = true | |
| - } | |
| - } | |
| - } | |
| + state := conn.ConnectionState() | |
| + log.Info("--- TLS Diagnostic Probe ---", slog.String("remote_addr", addr), slog.String("sni_sent", u.SNI)) | |
| + | |
| + for i, cert := range state.PeerCertificates { | |
| + log.Info(fmt.Sprintf("Certificate [%d] in chain", i), | |
| + slog.String("subject", cert.Subject.String()), | |
| + slog.String("issuer", cert.Issuer.String()), | |
| + //slog.Any("dns_names", cert.DNSNames), // This is the most important part | |
| + SafeStringSlice("dns_names", cert.DNSNames), | |
| + //slog.Any("ips", cert.IPAddresses), | |
| + SafeSlice("ips", cert.IPAddresses, net.IP.String), | |
| + slog.Time("expires", cert.NotAfter), | |
| + ) | |
| } | |
| +} | |
| - // 3. Assemble the final version string idiomatically | |
| - suffix := "" | |
| - //like this/(via `go version -m dnsbollocks.exe`): dep github.com/miekg/dns v1.1.73-0.20260402044838-d1539a788a12 | |
| - if vcsTime != "" { | |
| - suffix += "-0." + vcsTime // cantFIXME: Hardcodes the '0' generation counter before the timestamp (can't read/get it apparently) "Go's debug.ReadBuildInfo doesn't expose it; nothing to do" - Claude Sonnet 4.6 Low Thinking | |
| +func compareDNSResponses(a, b *dns.Msg) bool { | |
| + if a == nil || b == nil { | |
| + return a == b | |
| } | |
| - // Avoid duplicating the hash if the base version string already includes it | |
| - if vcsRevision != "" && !strings.Contains(baseVersion, vcsRevision) { | |
| - suffix += "-" + vcsRevision | |
| + if a.Rcode != b.Rcode { | |
| + return false | |
| } | |
| - if isModified { | |
| - suffix += "+dirty" | |
| + if len(a.Answer) != len(b.Answer) { | |
| + return false | |
| } | |
| - return baseVersion + suffix | |
| -}() //it's a func call | |
| + // Normalize answers by stripping out TTLs (as different caches will return different TTLs) | |
| + // and sorting them (as DNS Round Robin changes order). | |
| + getNorms := func(msg *dns.Msg) []string { | |
| + norms := make([]string, 0, len(msg.Answer)) | |
| + for _, rr := range msg.Answer { | |
| + clone := dns.Copy(rr) | |
| + clone.Header().Ttl = 0 | |
| + norms = append(norms, clone.String()) | |
| + } | |
| + sort.Strings(norms) | |
| + return norms | |
| + } | |
| -// GetVersion returns the cached build info string directly | |
| -func GetVersion() string { | |
| - return memoizedVersion | |
| -} | |
| + normsA := getNorms(a) | |
| + normsB := getNorms(b) | |
| -type UpstreamState struct { //doneTODO: rename Telemetry to something normal | |
| - Strategy string `json:"strategy"` | |
| - UpstreamUsed string `json:"upstream_used"` | |
| - FailedUpstreams []string `json:"failed_upstreams"` | |
| + for i := range normsA { | |
| + if normsA[i] != normsB[i] { | |
| + return false | |
| + } | |
| + } | |
| + return true | |
| } | |
| -// SafeRequestAttr extracts only the essential primitive data fields from an http.Request | |
| -// into a race-safe, highly readable slog.Attr group without using reflection(ie. slog.Any). | |
| -func SafeRequestAttr(key string, req *http.Request) slog.Attr { | |
| - if req == nil { | |
| - return slog.Group(key) | |
| - } | |
| +// Globals for static data | |
| +var ( | |
| + // This runs once at startup | |
| + edeText = func() string { | |
| + exePath, err := os.Executable() | |
| + if err != nil { | |
| + exePath = "DNSbollocks" | |
| + } | |
| + // Get startup time. "15:04:05" is the Go magic layout for HH:MM:SS | |
| + // You can also use time.DateOnly (2006-01-02) if you prefer | |
| + startTime := time.Now().Format("2006-01-02 15:04:05-0700") // don't need more precision here! | |
| + version := GetVersion() | |
| - return slog.Group(key, | |
| - slog.String("method", req.Method), | |
| - slog.String("url", req.URL.String()), | |
| - slog.String("proto", req.Proto), | |
| - slog.String("host", req.Host), | |
| - slog.String("content_type", req.Header.Get("Content-Type")), | |
| - ) | |
| -} | |
| + return fmt.Sprintf("Blocked by %q %q [which was started on %q]", exePath, version, startTime) | |
| + }() //it's a func call | |
| -func (u *Upstream) doSingleDoHRequest(ctx context.Context, reqBytes []byte) (*dns.Msg, error) { | |
| - log := u.getLogger() | |
| + edeCode = dns.ExtendedErrorCodeBlocked | |
| +) | |
| - if u.Client == nil { | |
| - panic2(fmt.Sprintf("BUG: dev fail: dohClient is still nil at calling doSingleDoHRequest! shouldn't happen! upstreamURL=%s SNI=%s", u.URL, u.SNI)) | |
| +func (s *Server) blockResponse(msg *dns.Msg) *dns.Msg { | |
| + cfg := s.getConfig() | |
| + | |
| + // Special-case: For AAAA queries, return NOERROR with an empty answer instead of NXDOMAIN. | |
| + // Windows treats NXDOMAIN for AAAA as authoritative non-existence which prevents IPv4 fallback. | |
| + // if you don't do this then, when you run the following in git-bash (git for windows's bash terminal): | |
| + // $ ssh -T git@github.com | |
| + // ssh: Could not resolve hostname github.com: Name or service not known | |
| + // because win11 service "DNS Client" aka "dnscache" does two AAAA queries to us which we reply with NXDOMAIN and it stops. | |
| + // if we reply with NOERROR and empty like this here, then it will try a third query as A which succeeds (if it's in the whitelist) | |
| + // IF A were whitelisted and thus we're reply with NOERROR here otherwise with NXDOMAIN then the problem is when a domain is initially blocked | |
| + // and we whitelist it in A afterwards, then dnscache might've cached the NXDOMAIN from AAAA and treat it as such for X more seconds thus | |
| + // it's best to always NODATA(aka NOERROR with 0 answers, as per Gemini) this here regardless of whether its A is or isn't allowed | |
| + // to avoid this case where dnscache win11 service caches the NXDOMAIN! | |
| + if cfg.BlockAAAAasEmptyNoError && len(msg.Question) > 0 && msg.Question[0].Qtype == dns.TypeAAAA && cfg.BlockMode == "nxdomain" { | |
| + resp := new(dns.Msg) | |
| + resp.SetReply(msg) | |
| + resp.Rcode = dns.RcodeSuccess | |
| + resp.Answer = []dns.RR{} | |
| + resp.Ns = []dns.RR{} | |
| + resp.Extra = []dns.RR{} | |
| + resp.Authoritative = true | |
| + resp.RecursionAvailable = true | |
| + // short TTL negative AAAA response is effectively encoded by empty answer; caching handled by caller | |
| + return resp | |
| } | |
| - retries := u.Retries //cfg.UpstreamRetriesPerQuery | |
| - if retries < 1 { | |
| - retries = 0 // Sanity check: must attempt at least once(see the 'for' below) | |
| + //in Go, implicit 'break' after each 'case' | |
| + switch cfg.BlockMode { //XXX: it's already lowercased! | |
| + case "nxdomain": | |
| + msg.SetRcode(msg, dns.RcodeNameError) // this is NXDOMAIN | |
| + case "ip_block", "block_ip", "ipblock", "blockip": | |
| + ttl := uint32(cfg.BlockedResponseTTLSec) | |
| + qtype := msg.Question[0].Qtype | |
| + switch qtype { | |
| + case dns.TypeA: | |
| + rr := new(dns.A) | |
| + rr.Hdr = dns.RR_Header{Name: msg.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: ttl} | |
| + rr.A = cfg.BlockIPv4Parsed // Thread-safe shared reference copy | |
| + msg.Answer = []dns.RR{rr} | |
| + msg.SetRcode(msg, dns.RcodeSuccess) | |
| + case dns.TypeAAAA: | |
| + rr := new(dns.AAAA) | |
| + rr.Hdr = dns.RR_Header{Name: msg.Question[0].Name, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: ttl} | |
| + rr.AAAA = cfg.BlockIPv6Parsed // Thread-safe shared reference copy | |
| + msg.Answer = []dns.RR{rr} | |
| + msg.SetRcode(msg, dns.RcodeSuccess) | |
| + default: | |
| + // non A or AAAA during this BlockMode? | |
| + /* | |
| + According to the DNS specifications (RFC 2308), if a domain name exists (i.e., it has an A or AAAA record), a query for any other record type on that same name (like TXT, MX, or SRV) must return NOERROR with an empty answer section (known as a NODATA response). | |
| + If you return NXDOMAIN (Name Error) for a TXT query on a domain where you just returned an IP address for an A query, downstream caching servers or the Windows dnscache service will cache that the entire domain does not exist. This will break your blocking mechanism or cause intermittent resolution failures. | |
| + */ | |
| + // For MX, TXT, etc., return an explicit NODATA response | |
| + // (Success with 0 answers) because the domain "exists" in our ip_block view. | |
| + msg.Answer = []dns.RR{} | |
| + msg.SetRcode(msg, dns.RcodeSuccess) | |
| + } | |
| + | |
| + case "drop": | |
| + return nil | |
| + default: | |
| + log := s.getLogger() | |
| + log.Warn("Unknown BlockMode in config file, falling back to NXDOMAIN", slog.String("blockmode", cfg.BlockMode)) | |
| + // fallback to nxdomain | |
| + msg.SetRcode(msg, dns.RcodeNameError) | |
| } | |
| - maxTries := 1 + retries | |
| - var resp *http.Response | |
| - var err4ClientDo error | |
| - var req *http.Request | |
| - var cancelCurrentReq func() // Track the active context cancel function across scopes | |
| + msg.Authoritative = true | |
| + msg.RecursionAvailable = true | |
| - //for attempt := range maxTries { // starts from 0 ! | |
| - for attempt := 1; attempt <= maxTries; attempt++ { | |
| - // Use an anonymous function wrapper so 'defer' operates on a per-iteration scope | |
| - failedToCreateRequest, errReq := func() (bool, error) { | |
| - // 1. Create a transient request context derived from the client's ctx | |
| - // reqCtx, cancelReq := context.WithCancel(ctx) | |
| - //NOTTRUEXXX: when the upstream IP is set to Deny in portmaster firewall after it worked before, without this context.WithTimeout it will hang forever until Ctrl+C cancels context then you see all the logs that show it was stuck. This is the only way. | |
| - // 1. Derive a timed-out context from your incoming request context (reqCtx) | |
| - reqCtx, cancelReq := context.WithTimeout(ctx, u.UpstreamClientTimeoutDuration) | |
| - // Crucial: always defer cancel to prevent context leaks! | |
| - // defer cancel() NO | |
| - | |
| - // If the server shuts down while this request is in-flight, cancel it. | |
| - // stopWatch() frees the AfterFunc's internal resources once we no longer need it. | |
| - stopWatch := context.AfterFunc(u.BackgroundCtx, cancelReq) | |
| - | |
| - // Use a flag to track if responsibility for calling cancelReq() has been handed off | |
| - var handedOver bool | |
| - defer func() { | |
| - if !handedOver { | |
| - stopWatch() // prevent AfterFunc from firing; safe no-op if already fired | |
| - cancelReq() // Clean up immediately on panic or retryable error | |
| - } | |
| - }() | |
| - | |
| - // // 2. Spin up a quick monitor to cancel the request if the application shuts down | |
| - // go func() { | |
| - // select { | |
| - // case <-u.BackgroundCtx.Done(): //this must be Server.ctx or s.ctx former backgroundCtx | |
| - // cancelReq() // Aborts the HTTP request immediately on Ctrl+C | |
| - // case <-reqCtx.Done(): | |
| - // // Normal exit when the request finishes or client disconnects | |
| - // } | |
| - // }() | |
| - | |
| - // 3. Pass the merged context to the HTTP request | |
| - | |
| - // create request with supplied context so caller controls deadline/cancel | |
| - var e error | |
| - req, e = http.NewRequestWithContext(reqCtx, http.MethodPost /*"POST"*/, u.URL.String(), bytes.NewReader(reqBytes)) | |
| - if e != nil { | |
| - // Wrap the error to give it context and satisfy wrapcheck | |
| - return true, fmt.Errorf("failed to create DoH HTTP request: %w", e /*non-nil*/) | |
| - } | |
| + // Re-allocate the OPT "envelope" but use the static EDE logic | |
| - req.Header.Set("Content-Type", "application/dns-message") | |
| - if u.SNI != "" { | |
| - req.Host = u.SNI | |
| - } | |
| + // 1. ALWAYS create the OPT "envelope" and calculate the safe UDP size. | |
| + // This is a crucial network optimization (EDNS0 Flag Day) that you want | |
| + // to send regardless of whether EDE text is enabled or not. | |
| + opt := new(dns.OPT) | |
| + opt.Hdr.Name = "." | |
| + opt.Hdr.Rrtype = dns.TypeOPT | |
| - log2 := u.getLogger() | |
| - log2.Debug("Attempting request to upstream", slog.String("url", u.URL.String()), slog.String("sni", u.SNI)) | |
| - // Capture the HTTP client execution | |
| - resp, err4ClientDo = u.Client.Do(req) // this is concurrency safe | |
| - if err4ClientDo == nil { | |
| - //success | |
| - //cancelCurrentReq = cancelReq // Hand off the cancellation function to the outer scope | |
| - // Hand ownership to outer scope. stopWatch must also be called there | |
| - // to free AfterFunc resources once the response body is consumed. | |
| - cancelCurrentReq = func() { | |
| - stopWatch() | |
| - cancelReq() | |
| - } | |
| - handedOver = true // Detach this iteration's deferred cleanup | |
| - } | |
| - return false, nil | |
| - }() | |
| + // Logic: If the client asked for something specific, | |
| + // we use 1232 as a "ceiling" to stay safe. | |
| + //In DNS, the UDPSize you set in the OPT header (opt.SetUDPSize(1232)) isn't the size of the current packet—it's an advertisement to the other side saying, "I am capable of receiving packets up to this size." | |
| + // 1. Start with your "Ideal" safety limit (1232) | |
| + ourMax := uint16(1232) | |
| - // If NewRequestWithContext failed, abort immediately just like the original logic | |
| - if failedToCreateRequest { | |
| - return nil, errReq | |
| + // 2. Check if the client specifically asked for less | |
| + if clientOpt := msg.IsEdns0(); clientOpt != nil { | |
| + if clientOpt.UDPSize() < ourMax { | |
| + ourMax = clientOpt.UDPSize() // Respect the client's smaller limit | |
| } | |
| + } | |
| - // If client.Do succeeded, we can stop retrying | |
| - if err4ClientDo == nil { //XXX: if you change or move this, the logic below changes drastically! be careful | |
| - //success! | |
| - break | |
| + // 3. Set the advertised size | |
| + opt.SetUDPSize(ourMax) | |
| + // 1232 is the "EDNS0 Flag Day" recommended value | |
| + // It prevents IP fragmentation on modern networks | |
| + //opt.SetUDPSize(1232) // Safer modern size, affects only current response. "What it actually does: When a client sends a query, it often includes its own OPT record saying "I can accept up to X bytes." By responding with SetUDPSize(1232), you are saying "I am sending this reply, and I'm letting you know my maximum limit is 1232."", "Future Queries: It does not bind future queries to that size. Each request/response pair is independent." | |
| + | |
| + // Only allocate memory for EDE and set the DNSSEC OK bit if the feature is actually enabled. | |
| + if cfg.UseEDEInBlockedReply { | |
| + opt.SetDo() // Set the "DNSSEC OK" bit; some browsers require this to process OPT records | |
| + // this EDE for firefox, not needed but should be easier for the user to see why DNS didn't work. | |
| + // 1. Manually build the EDE struct using the global variables | |
| + ede := &dns.EDNS0_EDE{ | |
| + InfoCode: edeCode, | |
| + ExtraText: edeText, | |
| } | |
| - //so we're here because the request error-ed | |
| + // You can reuse a global EDE struct here IF it is never modified | |
| + opt.Option = []dns.EDNS0{ede} | |
| + } | |
| - // decide if error is transient/retryable | |
| - // common retryable errors: temporary network errors, EOF, connection reset | |
| - var netErr net.Error | |
| - isRetryable := errors.Is(err4ClientDo, io.EOF) || errors.Is(err4ClientDo, io.ErrUnexpectedEOF) || | |
| - errors.Is(err4ClientDo, syscall.ECONNRESET) || // Since you are on Windows, syscall.ECONNRESET is actually mapped to the Windows-specific WSAECONNRESET code internally by the Go net package, so errors.Is will work correctly across platforms if you ever decide to compile this for Linux/macOS too. | |
| - errors.Is(err4ClientDo, syscall.ECONNREFUSED) || | |
| - (errors.As(err4ClientDo, &netErr) && netErr.Timeout()) //netErr.Timeout(): This is the "official" way to check for timeouts now. It covers both the network dial timing out and your http.Client.Timeout. | |
| + // 3. Append the envelope to the response | |
| + msg.Extra = append(msg.Extra, opt) | |
| - if isRetryable { | |
| - log.Error("doh_post_transient_error for this query", SafeErr(err4ClientDo), | |
| - slog.Int("current_try", attempt), slog.Int("max_tries", maxTries), | |
| - //slog.Any("query", req), | |
| - SafeRequestAttr("query", req), | |
| - slog.Bool("will_retry", attempt < maxTries)) | |
| + return msg | |
| +} | |
| - // 🔴 FIX #1: If this was the last attempt, return the REAL error immediately! | |
| - // This prevents falling through to the bottom of the function. | |
| - if attempt >= maxTries { | |
| - return nil, fmt.Errorf("exhausted %d/%d tries to upstream DoH, last request's err: %w", attempt, maxTries, err4ClientDo /*non-nil here*/) | |
| - } | |
| - if u.RetryBackoffDuration <= 0 { | |
| - u.RetryBackoffDuration = time.Duration(100) * time.Millisecond | |
| - log.Warn("BUG: retry backoff timer is set to <= 0 , preventing hang by using 100ms", slog.Duration("retrybackoff_duration", u.RetryBackoffDuration)) | |
| - } else if u.RetryBackoffDuration >= time.Duration(5)*time.Second { | |
| - log.Warn("RetryBackoffDuration is >= 5 sec", slog.Duration("retrybackoff_duration", u.RetryBackoffDuration)) | |
| - } | |
| - // small backoff: sleep a bit but respect context | |
| - select { | |
| - case <-time.After(u.RetryBackoffDuration): | |
| - log.Debug("Retrying after backoff", SafeRequestAttr("query", req), slog.Duration("retrybackoff_duration", u.RetryBackoffDuration)) | |
| - //exits select | |
| - case <-ctx.Done(): | |
| - log.Debug("doh sensed client quit during retry backoff...") | |
| - return nil, fmt.Errorf("doh sensed client quit during retry backoff... ctx.err: %w", ctx.Err() /*non-nil guaranteed*/) | |
| - case <-u.BackgroundCtx.Done(): | |
| - log.Debug("doh sensed quit during retry backoff...") | |
| - return nil, fmt.Errorf("doh sensed quit during retry backoff... bkgctx.err: %w", u.BackgroundCtx.Err() /*non-nil guaranteed*/) | |
| - } | |
| - continue //next try | |
| - } | |
| - // non-retryable error | |
| - // --- NEW DIAGNOSTIC BLOCK --- | |
| - if strings.Contains(err4ClientDo.Error(), "tls:") || strings.Contains(err4ClientDo.Error(), "x509:") { | |
| - log.Error("TLS verification failed when tried to query upstream DNS server", | |
| - slog.String("url", u.URL.String()), | |
| - slog.String("sni_used", u.SNI), | |
| - SafeErr(err4ClientDo)) | |
| +const NODATA string = "upstream_nodata" | |
| +const BlockedZeroIP string = "blocked_ZeroIP" | |
| +const BlockedBlacklistedIP string = "blocked_blacklisted_ip" | |
| +const StrippedRRSIG string = "stripped_rrsig" | |
| - // Run a manual probe to see what the server is actually sending | |
| - u.logCertDetails() //targetURL.Hostname(), targetURL.Port(), sni) | |
| - } else { | |
| - log.Error("Failed to query upstream DNS server", | |
| - slog.String("url", u.URL.String()), | |
| - slog.String("sni_used", u.SNI), | |
| - SafeErr(err4ClientDo)) | |
| - } | |
| - // --- END DIAGNOSTIC BLOCK --- | |
| - return nil, fmt.Errorf("failed to send the HTTP request to the upstream DoH server %q, err: %w", u.URL.String(), err4ClientDo /*non-nil here*/) | |
| - } //for retries | |
| +const BlockedByUpstream string = "blockedByUpstream_ZeroIP" | |
| - // --- THE CODE BELOW ONLY EXECUTES ON SUCCESSFUL BREAK --- | |
| +// mutates the passed arg | |
| +func filterResponse(log *slog.Logger, msg *dns.Msg, removeHTTPSIPv4Hints bool, configKeyName string, blacklist IPChecker) (*dns.Msg, string) { | |
| + //log := s.getLogger() | |
| - // ✅ Ensure the active context gets cancelled when the outer function returns | |
| - if cancelCurrentReq != nil { | |
| - defer cancelCurrentReq() | |
| + if msg == nil { | |
| + panic2("BUG: msg was nil, unexpected bad programming/code ;p") | |
| } | |
| - | |
| - if resp == nil { | |
| - // last attempt produced no response (shouldn't happen), treat as failure | |
| - log.Error("doh_no_response") | |
| - return nil, errors.New("no response") | |
| - } else { | |
| - defer resp.Body.Close() //nolint:errcheck // best-effort close, nothing to do on error | |
| + if len(msg.Question) == 0 { | |
| + panic2("BUG: no DNS question! unexpected bad programming/code ;p") | |
| } | |
| - // ✅ This will now execute perfectly! The context is guaranteed to stay alive here. | |
| - body, err4ReadAll := io.ReadAll(resp.Body) | |
| - if err4ReadAll != nil { | |
| - log.Error("doh_readbody_failed", SafeErr(err4ReadAll)) | |
| - return nil, fmt.Errorf("failed to read upstream DoH response body: %w", err4ReadAll /*non-nil here*/) | |
| - } | |
| + q := msg.Question[0] | |
| + qtype := dns.TypeToString[q.Qtype] // Map lookup | |
| - // debug/log non-200 or unexpected content-type | |
| - if resp.StatusCode != 200 { | |
| - log.Error("doh_upstream_status", slog.String("status", resp.Status)) | |
| - return nil, fmt.Errorf("upstream status %s", resp.Status) | |
| - } | |
| - ct := resp.Header.Get("Content-Type") | |
| - if ct != "application/dns-message" { | |
| - log.Error("doh_upstream_content_type isn't the expected application/dns-message", slog.String("content_type", ct)) | |
| - } | |
| - if len(body) < 12 { | |
| - log.Error("doh_upstream_body_too_short", slog.Int("len", len(body))) | |
| + // If upstream naturally returned NOERROR with 0 answers (NODATA), let it through! | |
| + if len(msg.Answer) == 0 && len(msg.Ns) == 0 && len(msg.Extra) == 0 { | |
| + return msg, NODATA | |
| } | |
| - upMsg := new(dns.Msg) | |
| - if err4Unpack := upMsg.Unpack(body); err4Unpack != nil { | |
| - n := len(body) | |
| - log.Error("doh_unpack_failed", SafeErr(err4Unpack), | |
| - slog.String("body_hex", fmt.Sprintf("Upstream body (hex, first %d): %x", n, body[:n])), | |
| - slog.String("body_text", fmt.Sprintf("Upstream body (text, first %d): %q", n, body[:n])), | |
| - ) | |
| - return nil, fmt.Errorf("failed to unpack response body for upstream DoH %q, err: %w", u.URL.String(), err4Unpack /*non-nil here*/) | |
| - } | |
| - return upMsg, nil | |
| -} | |
| + var dropReasons []string | |
| -func (u *Upstream) logCertDetails() { //(ip, port, sni string) { | |
| - log := u.getLogger() | |
| + // Define a local closure to process any arbitrary DNS section | |
| + filterSection := func(records []dns.RR, sectionName string) []dns.RR { | |
| + var good []dns.RR | |
| + for _, rr := range records { | |
| + if keep, modifiedRR, reason := processRR(log, rr, removeHTTPSIPv4Hints, configKeyName, blacklist); keep { | |
| + good = append(good, modifiedRR) | |
| + } else { | |
| + // Captures and mutates 'dropReasons' from the outer scope automatically | |
| + dropReasons = append(dropReasons, reason) | |
| - port := u.URL.Port() | |
| - if port == "" { | |
| - //TODO: replace all panics with logFatal() ? | |
| - panic2("BUG: dev fail: port is empty but shoulda been set in validateUpstream() to 443") | |
| + log.Warn("Dropped "+sectionName+" from upstream", | |
| + slog.String("reason", reason), | |
| + slog.String("query_type", qtype), | |
| + slog.String("rr", rr.String() /*non-nil if here due to 'for' was entered*/), | |
| + ) | |
| + } | |
| + } | |
| + return good | |
| } | |
| - addr := net.JoinHostPort(u.URL.Hostname(), port) | |
| - dialer := &net.Dialer{Timeout: time.Duration(u.CertLogTimeoutSec) * time.Second} | |
| - // XXX: We use InsecureSkipVerify: true ONLY for this probe so we can read the cert | |
| - // that was otherwise rejected. | |
| - conn, err := tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{ | |
| - ServerName: u.SNI, | |
| - InsecureSkipVerify: true, | |
| - }) | |
| - | |
| - if err != nil { | |
| - log.Error("Diagnostic probe failed", slog.String("addr", addr), SafeErr(err)) | |
| - return | |
| - } else { | |
| - defer conn.Close() //nolint:errcheck // best-effort close, nothing to do on error | |
| - } | |
| - | |
| - state := conn.ConnectionState() | |
| - log.Info("--- TLS Diagnostic Probe ---", slog.String("remote_addr", addr), slog.String("sni_sent", u.SNI)) | |
| - | |
| - for i, cert := range state.PeerCertificates { | |
| - log.Info(fmt.Sprintf("Certificate [%d] in chain", i), | |
| - slog.String("subject", cert.Subject.String()), | |
| - slog.String("issuer", cert.Issuer.String()), | |
| - //slog.Any("dns_names", cert.DNSNames), // This is the most important part | |
| - SafeStringSlice("dns_names", cert.DNSNames), | |
| - //slog.Any("ips", cert.IPAddresses), | |
| - SafeSlice("ips", cert.IPAddresses, net.IP.String), | |
| - slog.Time("expires", cert.NotAfter), | |
| - ) | |
| - } | |
| -} | |
| - | |
| -func compareDNSResponses(a, b *dns.Msg) bool { | |
| - if a == nil || b == nil { | |
| - return a == b | |
| - } | |
| - if a.Rcode != b.Rcode { | |
| - return false | |
| - } | |
| - if len(a.Answer) != len(b.Answer) { | |
| - return false | |
| - } | |
| - | |
| - // Normalize answers by stripping out TTLs (as different caches will return different TTLs) | |
| - // and sorting them (as DNS Round Robin changes order). | |
| - getNorms := func(msg *dns.Msg) []string { | |
| - norms := make([]string, 0, len(msg.Answer)) | |
| - for _, rr := range msg.Answer { | |
| - clone := dns.Copy(rr) | |
| - clone.Header().Ttl = 0 | |
| - norms = append(norms, clone.String()) | |
| - } | |
| - sort.Strings(norms) | |
| - return norms | |
| - } | |
| - | |
| - normsA := getNorms(a) | |
| - normsB := getNorms(b) | |
| - | |
| - for i := range normsA { | |
| - if normsA[i] != normsB[i] { | |
| - return false | |
| - } | |
| - } | |
| - return true | |
| -} | |
| - | |
| -// Globals for static data | |
| -var ( | |
| - // This runs once at startup | |
| - edeText = func() string { | |
| - exePath, err := os.Executable() | |
| - if err != nil { | |
| - exePath = "DNSbollocks" | |
| - } | |
| - // Get startup time. "15:04:05" is the Go magic layout for HH:MM:SS | |
| - // You can also use time.DateOnly (2006-01-02) if you prefer | |
| - startTime := time.Now().Format("2006-01-02 15:04:05-0700") // don't need more precision here! | |
| - version := GetVersion() | |
| - | |
| - return fmt.Sprintf("Blocked by %q %q [which was started on %q]", exePath, version, startTime) | |
| - }() //it's a func call | |
| - | |
| - edeCode = dns.ExtendedErrorCodeBlocked | |
| -) | |
| - | |
| -func (s *Server) blockResponse(msg *dns.Msg) *dns.Msg { | |
| - cfg := s.getConfig() | |
| - | |
| - // Special-case: For AAAA queries, return NOERROR with an empty answer instead of NXDOMAIN. | |
| - // Windows treats NXDOMAIN for AAAA as authoritative non-existence which prevents IPv4 fallback. | |
| - // if you don't do this then, when you run the following in git-bash (git for windows's bash terminal): | |
| - // $ ssh -T git@github.com | |
| - // ssh: Could not resolve hostname github.com: Name or service not known | |
| - // because win11 service "DNS Client" aka "dnscache" does two AAAA queries to us which we reply with NXDOMAIN and it stops. | |
| - // if we reply with NOERROR and empty like this here, then it will try a third query as A which succeeds (if it's in the whitelist) | |
| - // IF A were whitelisted and thus we're reply with NOERROR here otherwise with NXDOMAIN then the problem is when a domain is initially blocked | |
| - // and we whitelist it in A afterwards, then dnscache might've cached the NXDOMAIN from AAAA and treat it as such for X more seconds thus | |
| - // it's best to always NODATA(aka NOERROR with 0 answers, as per Gemini) this here regardless of whether its A is or isn't allowed | |
| - // to avoid this case where dnscache win11 service caches the NXDOMAIN! | |
| - if cfg.BlockAAAAasEmptyNoError && len(msg.Question) > 0 && msg.Question[0].Qtype == dns.TypeAAAA && cfg.BlockMode == "nxdomain" { | |
| - resp := new(dns.Msg) | |
| - resp.SetReply(msg) | |
| - resp.Rcode = dns.RcodeSuccess | |
| - resp.Answer = []dns.RR{} | |
| - resp.Ns = []dns.RR{} | |
| - resp.Extra = []dns.RR{} | |
| - resp.Authoritative = true | |
| - resp.RecursionAvailable = true | |
| - // short TTL negative AAAA response is effectively encoded by empty answer; caching handled by caller | |
| - return resp | |
| - } | |
| - | |
| - //in Go, implicit 'break' after each 'case' | |
| - switch cfg.BlockMode { //XXX: it's already lowercased! | |
| - case "nxdomain": | |
| - msg.SetRcode(msg, dns.RcodeNameError) // this is NXDOMAIN | |
| - case "ip_block", "block_ip", "ipblock", "blockip": | |
| - ttl := uint32(cfg.BlockedResponseTTLSec) | |
| - qtype := msg.Question[0].Qtype | |
| - switch qtype { | |
| - case dns.TypeA: | |
| - rr := new(dns.A) | |
| - rr.Hdr = dns.RR_Header{Name: msg.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: ttl} | |
| - rr.A = cfg.BlockIPv4Parsed // Thread-safe shared reference copy | |
| - msg.Answer = []dns.RR{rr} | |
| - msg.SetRcode(msg, dns.RcodeSuccess) | |
| - case dns.TypeAAAA: | |
| - rr := new(dns.AAAA) | |
| - rr.Hdr = dns.RR_Header{Name: msg.Question[0].Name, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: ttl} | |
| - rr.AAAA = cfg.BlockIPv6Parsed // Thread-safe shared reference copy | |
| - msg.Answer = []dns.RR{rr} | |
| - msg.SetRcode(msg, dns.RcodeSuccess) | |
| - default: | |
| - // non A or AAAA during this BlockMode? | |
| - /* | |
| - According to the DNS specifications (RFC 2308), if a domain name exists (i.e., it has an A or AAAA record), a query for any other record type on that same name (like TXT, MX, or SRV) must return NOERROR with an empty answer section (known as a NODATA response). | |
| - If you return NXDOMAIN (Name Error) for a TXT query on a domain where you just returned an IP address for an A query, downstream caching servers or the Windows dnscache service will cache that the entire domain does not exist. This will break your blocking mechanism or cause intermittent resolution failures. | |
| - */ | |
| - // For MX, TXT, etc., return an explicit NODATA response | |
| - // (Success with 0 answers) because the domain "exists" in our ip_block view. | |
| - msg.Answer = []dns.RR{} | |
| - msg.SetRcode(msg, dns.RcodeSuccess) | |
| - } | |
| - | |
| - case "drop": | |
| - return nil | |
| - default: | |
| - log := s.getLogger() | |
| - log.Warn("Unknown BlockMode in config file, falling back to NXDOMAIN", slog.String("blockmode", cfg.BlockMode)) | |
| - // fallback to nxdomain | |
| - msg.SetRcode(msg, dns.RcodeNameError) | |
| - } | |
| - | |
| - msg.Authoritative = true | |
| - msg.RecursionAvailable = true | |
| - | |
| - // Re-allocate the OPT "envelope" but use the static EDE logic | |
| - | |
| - // 1. ALWAYS create the OPT "envelope" and calculate the safe UDP size. | |
| - // This is a crucial network optimization (EDNS0 Flag Day) that you want | |
| - // to send regardless of whether EDE text is enabled or not. | |
| - opt := new(dns.OPT) | |
| - opt.Hdr.Name = "." | |
| - opt.Hdr.Rrtype = dns.TypeOPT | |
| - | |
| - // Logic: If the client asked for something specific, | |
| - // we use 1232 as a "ceiling" to stay safe. | |
| - //In DNS, the UDPSize you set in the OPT header (opt.SetUDPSize(1232)) isn't the size of the current packet—it's an advertisement to the other side saying, "I am capable of receiving packets up to this size." | |
| - // 1. Start with your "Ideal" safety limit (1232) | |
| - ourMax := uint16(1232) | |
| - | |
| - // 2. Check if the client specifically asked for less | |
| - if clientOpt := msg.IsEdns0(); clientOpt != nil { | |
| - if clientOpt.UDPSize() < ourMax { | |
| - ourMax = clientOpt.UDPSize() // Respect the client's smaller limit | |
| - } | |
| - } | |
| - | |
| - // 3. Set the advertised size | |
| - opt.SetUDPSize(ourMax) | |
| - // 1232 is the "EDNS0 Flag Day" recommended value | |
| - // It prevents IP fragmentation on modern networks | |
| - //opt.SetUDPSize(1232) // Safer modern size, affects only current response. "What it actually does: When a client sends a query, it often includes its own OPT record saying "I can accept up to X bytes." By responding with SetUDPSize(1232), you are saying "I am sending this reply, and I'm letting you know my maximum limit is 1232."", "Future Queries: It does not bind future queries to that size. Each request/response pair is independent." | |
| - | |
| - // Only allocate memory for EDE and set the DNSSEC OK bit if the feature is actually enabled. | |
| - if cfg.UseEDEInBlockedReply { | |
| - opt.SetDo() // Set the "DNSSEC OK" bit; some browsers require this to process OPT records | |
| - // this EDE for firefox, not needed but should be easier for the user to see why DNS didn't work. | |
| - // 1. Manually build the EDE struct using the global variables | |
| - ede := &dns.EDNS0_EDE{ | |
| - InfoCode: edeCode, | |
| - ExtraText: edeText, | |
| - } | |
| - | |
| - // You can reuse a global EDE struct here IF it is never modified | |
| - opt.Option = []dns.EDNS0{ede} | |
| - } | |
| - | |
| - // 3. Append the envelope to the response | |
| - msg.Extra = append(msg.Extra, opt) | |
| - | |
| - return msg | |
| -} | |
| - | |
| -const NODATA string = "upstream_nodata" | |
| -const BlockedZeroIP string = "blocked_ZeroIP" | |
| -const BlockedBlacklistedIP string = "blocked_blacklisted_ip" | |
| -const StrippedRRSIG string = "stripped_rrsig" | |
| - | |
| -const BlockedByUpstream string = "blockedByUpstream_ZeroIP" | |
| - | |
| -// mutates the passed arg | |
| -func filterResponse(log *slog.Logger, msg *dns.Msg, removeHTTPSIPv4Hints bool, configKeyName string, blacklist IPChecker) (*dns.Msg, string) { | |
| - //log := s.getLogger() | |
| - | |
| - if msg == nil { | |
| - panic2("BUG: msg was nil, unexpected bad programming/code ;p") | |
| - } | |
| - if len(msg.Question) == 0 { | |
| - panic2("BUG: no DNS question! unexpected bad programming/code ;p") | |
| - } | |
| - | |
| - q := msg.Question[0] | |
| - qtype := dns.TypeToString[q.Qtype] // Map lookup | |
| - | |
| - // If upstream naturally returned NOERROR with 0 answers (NODATA), let it through! | |
| - if len(msg.Answer) == 0 && len(msg.Ns) == 0 && len(msg.Extra) == 0 { | |
| - return msg, NODATA | |
| - } | |
| - | |
| - var dropReasons []string | |
| - | |
| - // Define a local closure to process any arbitrary DNS section | |
| - filterSection := func(records []dns.RR, sectionName string) []dns.RR { | |
| - var good []dns.RR | |
| - for _, rr := range records { | |
| - if keep, modifiedRR, reason := processRR(log, rr, removeHTTPSIPv4Hints, configKeyName, blacklist); keep { | |
| - good = append(good, modifiedRR) | |
| - } else { | |
| - // Captures and mutates 'dropReasons' from the outer scope automatically | |
| - dropReasons = append(dropReasons, reason) | |
| - | |
| - log.Warn("Dropped "+sectionName+" from upstream", | |
| - slog.String("reason", reason), | |
| - slog.String("query_type", qtype), | |
| - slog.String("rr", rr.String() /*non-nil if here due to 'for' was entered*/), | |
| - ) | |
| - } | |
| - } | |
| - return good | |
| - } | |
| - | |
| - // Re-assign the filtered slices directly back to the message | |
| - msg.Answer = filterSection(msg.Answer, "inAnswer") | |
| - msg.Extra = filterSection(msg.Extra, "inExtra") | |
| + // Re-assign the filtered slices directly back to the message | |
| + msg.Answer = filterSection(msg.Answer, "inAnswer") | |
| + msg.Extra = filterSection(msg.Extra, "inExtra") | |
| //if len(msg.Answer) == 0 { // this dropped HTTPS replies and they were thus not seen at all, so seen as blockedbyUpstream | |
| if len(msg.Answer) == 0 && len(msg.Ns) == 0 && len(msg.Extra) == 0 { | |
| @@ -9588,28 +9080,27 @@ func (ui *AdminUI) configHandler(w http.ResponseWriter, r *http.Request) { | |
| } | |
| // Resolve tags in the dry-run so hard-checks don't crash on the literal {file:...} string | |
| resolved, err5 := resolveConfigTags(&testCfg) | |
| - //if _, err := resolveConfigTags(&testCfg); err != nil { | |
| if err5 != nil { | |
| http.Error(w, "Validation failed (tag resolution): "+err5.Error(), http.StatusBadRequest) | |
| return | |
| } | |
| - if len(resolved.UpstreamSNIHostnames) > len(resolved.UpstreamURLs) { | |
| - http.Error(w, "there are more SNIs vs URLs for upstream, only the opposite is allowed ( >= URLs than SNIs which then inherit the SNI from URLs)", http.StatusBadRequest) | |
| + // --- RUN UNIFIED SANITIZE AND VALIDATE --- | |
| + // Ensure it receives identical clamping, normalization, and bounds checking. | |
| + defCfg := defaultConfig() | |
| + _, errValid := sanitizeAndValidateConfig(log, resolved, &rawCfg, &defCfg, true) | |
| + if errValid != nil { | |
| + http.Error(w, "Validation failed: "+errValid.Error(), http.StatusBadRequest) | |
| return | |
| } | |
| - //TODO: make the SNIs inherit from URLs, if missing, like loadMainConfig() already does, but DRY the common code! | |
| - // Hard-check URLs to prevent loadMainConfig panics | |
| - for i, rawURL := range resolved.UpstreamURLs { | |
| - if _, err8 := url.Parse(rawURL); err8 != nil { | |
| - http.Error(w, fmt.Sprintf("Invalid upstream URL at index %d: %v", i, err8), http.StatusBadRequest) | |
| - return | |
| - } | |
| - if _, err9 := hostFromURL(rawURL); err9 != nil { | |
| - http.Error(w, fmt.Sprintf("Invalid upstream host at index %d: %v", i, err9), http.StatusBadRequest) | |
| - return | |
| - } | |
| + // Since sanitizeAndValidateConfig actively clamps unsafe values inside rawCfg | |
| + // (e.g. SNIs paddings or limits corrections), we MUST re-marshal it! | |
| + newData, err12 = json.MarshalIndent(rawCfg, "", " ") | |
| + if err12 != nil { | |
| + log.Error("Failed to re-marshal sanitized config", SafeErr(err12)) | |
| + http.Error(w, "failed to re-marshal config after sanitization", http.StatusInternalServerError) | |
| + return | |
| } | |
| // Commit to disk and trigger hot-reload | |
| @@ -9666,334 +9157,890 @@ func retryFileOp(maxAttempts int, backoff time.Duration, fn func() error) error | |
| return lastErr | |
| } | |
| -// truncateStagingFileToZero opens the staging file with O_TRUNC (destroying | |
| -// its contents in-place, no delete/rename required), syncs the 0-byte state | |
| -// to disk, and closes it. This is the fallback path when the staging file | |
| -// can't be deleted outright but must not be left containing non-zero bytes, | |
| -// since CheckPowerLossFile treats any non-empty staging file as evidence of | |
| -// a crash mid-write on the next boot. | |
| -func truncateStagingFileToZero(tmpName string, perm os.FileMode) error { | |
| - truncFile, openErr := os.OpenFile(tmpName, os.O_WRONLY|os.O_TRUNC, perm) | |
| - if openErr != nil { | |
| - return fmt.Errorf("open for truncate failed: %w", openErr) | |
| +// truncateStagingFileToZero opens the staging file with O_TRUNC (destroying | |
| +// its contents in-place, no delete/rename required), syncs the 0-byte state | |
| +// to disk, and closes it. This is the fallback path when the staging file | |
| +// can't be deleted outright but must not be left containing non-zero bytes, | |
| +// since CheckPowerLossFile treats any non-empty staging file as evidence of | |
| +// a crash mid-write on the next boot. | |
| +func truncateStagingFileToZero(tmpName string, perm os.FileMode) error { | |
| + truncFile, openErr := os.OpenFile(tmpName, os.O_WRONLY|os.O_TRUNC, perm) | |
| + if openErr != nil { | |
| + return fmt.Errorf("open for truncate failed: %w", openErr) | |
| + } | |
| + | |
| + syncErr := truncFile.Sync() // Ensure the 0-byte state hits disk | |
| + closeErr := truncFile.Close() | |
| + | |
| + if syncErr != nil && closeErr != nil { | |
| + return fmt.Errorf("sync after truncate failed: %w (close also failed: %w)", syncErr, closeErr) | |
| + } | |
| + if syncErr != nil { | |
| + return fmt.Errorf("sync after truncate failed: %w", syncErr) | |
| + } | |
| + if closeErr != nil { | |
| + return fmt.Errorf("close after truncate+sync failed: %w", closeErr) | |
| + } | |
| + | |
| + return nil | |
| +} | |
| + | |
| +// writeSyncedFile opens filename with the given flags, writes data, syncs, | |
| +// and closes as a single retryable unit. A mid-write failure from a | |
| +// transient Windows file lock is exactly as retryable as an open failure, | |
| +// so this covers both together rather than only guarding OpenFile. | |
| +func writeSyncedFile(filename string, data []byte, flags int, perm os.FileMode) error { | |
| + f, err := os.OpenFile(filename, flags, perm) | |
| + if err != nil { | |
| + return fmt.Errorf("open failed: %w", err) | |
| + } | |
| + n, writeErr := f.Write(data) | |
| + syncErr := f.Sync() | |
| + closeErr := f.Close() | |
| + | |
| + if writeErr != nil { | |
| + return fmt.Errorf("write failed after %d/%d bytes: %w", n, len(data), writeErr) | |
| + } | |
| + if syncErr != nil { | |
| + return fmt.Errorf("sync failed: %w", syncErr) | |
| + } | |
| + if closeErr != nil { | |
| + return fmt.Errorf("close failed: %w", closeErr) | |
| + } | |
| + return nil | |
| +} | |
| + | |
| +var ( | |
| + kernel32 = windows.NewLazySystemDLL("kernel32.dll") | |
| + procSetConsoleCtrlHandler = kernel32.NewProc("SetConsoleCtrlHandler") | |
| + | |
| + // Global bridge so our Win32 callback can reach your Server instance | |
| + globalConsoleEventTrigger func(eventName string) | |
| + | |
| + // NEW: Flag to bypass the "Press any key" pause during forced teardowns | |
| + skipInteractivePause atomic.Bool | |
| +) | |
| + | |
| +// consoleCtrlHandler must be a top-level function with no free variables for windows.NewCallback() | |
| +func consoleCtrlHandler(ctrlType uint32) uintptr { | |
| + const ( | |
| + CtrlCEvent = windows.CTRL_C_EVENT //0 | |
| + CtrlBreakEvent = windows.CTRL_BREAK_EVENT //1 | |
| + CtrlCloseEvent = windows.CTRL_CLOSE_EVENT //2 | |
| + CtrlLogoffEvent = windows.CTRL_LOGOFF_EVENT //5 | |
| + CtrlShutdownEvent = windows.CTRL_SHUTDOWN_EVENT //6 | |
| + ) | |
| + | |
| + var eventName string | |
| + switch ctrlType { | |
| + case CtrlCEvent: | |
| + eventName = "CTRL_C_EVENT (Ctrl+C)" //it's the sigChan one that triggers tho (this one does only while in shutdown()) | |
| + case CtrlBreakEvent: | |
| + eventName = "CTRL_BREAK_EVENT (Ctrl+Break)" | |
| + case CtrlCloseEvent: | |
| + skipInteractivePause.Store(true) // <-- Bypass pause! Console is closing. | |
| + eventName = "CTRL_CLOSE_EVENT (Console Window Closed)" | |
| + case CtrlLogoffEvent: | |
| + skipInteractivePause.Store(true) // <-- Bypass pause! User is logging out. | |
| + eventName = "CTRL_LOGOFF_EVENT (User Logoff)" | |
| + case CtrlShutdownEvent: | |
| + skipInteractivePause.Store(true) // <-- Bypass pause! OS is shutting down. | |
| + eventName = "CTRL_SHUTDOWN_EVENT (System Shutdown)" | |
| + default: | |
| + // Return 0 (FALSE) for unhandled events so Windows continues standard routing | |
| + return 0 | |
| + } | |
| + | |
| + if globalConsoleEventTrigger != nil { | |
| + // This will block, eventually calling os.Exit() from inside your shutdown sequence. | |
| + // This is required. If we returned 1 immediately, the OS would kill the process mid-cleanup. | |
| + globalConsoleEventTrigger(eventName) | |
| + } | |
| + | |
| + return 1 // TRUE (Though os.Exit will usually fire before we ever reach this line) | |
| +} | |
| + | |
| +var reservedNames = map[string]struct{}{ | |
| + "CON": {}, "PRN": {}, "AUX": {}, "NUL": {}, | |
| + "COM1": {}, "COM2": {}, "COM3": {}, "COM4": {}, "COM5": {}, | |
| + "COM6": {}, "COM7": {}, "COM8": {}, "COM9": {}, | |
| + "LPT1": {}, "LPT2": {}, "LPT3": {}, "LPT4": {}, "LPT5": {}, | |
| + "LPT6": {}, "LPT7": {}, "LPT8": {}, "LPT9": {}, | |
| +} | |
| + | |
| +// resolveTag extracts the content of {file:filename} or {env:VAR}. | |
| +// It returns the resolved text, a boolean indicating if a tag was processed, and an error if it fails. | |
| +func resolveTag(input string) (resolved string, isTag bool, err error) { | |
| + input = strings.TrimSpace(input) | |
| + | |
| + if strings.HasPrefix(input, "{file:") && strings.HasSuffix(input, "}") { | |
| + original := strings.TrimSpace(input[6 : len(input)-1]) | |
| + | |
| + filename := strings.TrimRight(original, ". ") | |
| + filename = strings.ToUpper(filename) | |
| + // Strict directory traversal prevention | |
| + if strings.ContainsAny(filename, `/\:`) { | |
| + return "", true, fmt.Errorf("path separators not allowed in {file:...} — must be in same directory: %q", filename) | |
| + } | |
| + if _, bad := reservedNames[filename]; bad { | |
| + return "", true, fmt.Errorf("reserved Windows filename original: %q, processed:%q", original, filename) | |
| + } | |
| + configDir := filepath.Dir(configFileName) | |
| + path := filepath.Join(configDir, filename) //in same dir as config.json | |
| + data, err := os.ReadFile(path) | |
| + if err != nil { | |
| + return "", true, fmt.Errorf("failed to read external config file %q: %w", path, err) | |
| + } | |
| + // Trim trailing newlines commonly found in text files | |
| + return strings.TrimSpace(string(data)), true, nil | |
| + } | |
| + | |
| + if strings.HasPrefix(input, "{env:") && strings.HasSuffix(input, "}") { | |
| + envVar := strings.TrimSpace(input[5 : len(input)-1]) | |
| + val, ok := os.LookupEnv(envVar) | |
| + if !ok { | |
| + return "", true, fmt.Errorf("required environment variable %q is not set", envVar) | |
| + } | |
| + return strings.TrimSpace(val), true, nil | |
| + } | |
| + | |
| + return input, false, nil | |
| +} | |
| + | |
| +// resolveConfigTags returns a deep-copied *Config with every {file:...} and | |
| +// {env:...} token in string and []string fields expanded to its real value. | |
| +// The input raw is never mutated; all changes live in the returned copy. | |
| +func resolveConfigTags(raw *Config) (*Config, error) { | |
| + if raw == nil { | |
| + return nil, errors.New("nil config") | |
| + } | |
| + resolved0 := raw.Clone() | |
| + resolved := &resolved0 | |
| + | |
| + v := reflect.ValueOf(resolved).Elem() | |
| + t := v.Type() | |
| + | |
| + for i := 0; i < t.NumField(); i++ { | |
| + field := t.Field(i) | |
| + jsonTag := field.Tag.Get("json") | |
| + if jsonTag == "" || jsonTag == "-" { | |
| + continue | |
| + } | |
| + jsonKey := strings.Split(jsonTag, ",")[0] | |
| + val := v.Field(i) | |
| + | |
| + //The reflection loop is not constructing a new Config. It's only modifying certain fields in place. | |
| + vk := val.Kind() | |
| + switch vk { //nolint:exhaustive //only handing the cases that we know are template-able | |
| + case reflect.String: | |
| + str := val.String() | |
| + resolvedStr, isTag, err := resolveTag(str) | |
| + if isTag { | |
| + if err != nil { | |
| + return nil, fmt.Errorf("field %q resolution failed: %w", jsonKey, err) | |
| + } | |
| + val.SetString(resolvedStr) | |
| + } | |
| + case reflect.Slice: | |
| + if val.Type().Elem().Kind() != reflect.String { | |
| + panic2(fmt.Sprintf("BUG: dev-unhandled case for reflect.Slice that isn't string but it's %q", vk)) | |
| + continue | |
| + } | |
| + for j := 0; j < val.Len(); j++ { | |
| + str := val.Index(j).String() | |
| + resolvedStr, isTag, err := resolveTag(str) | |
| + if isTag { | |
| + if err != nil { | |
| + return nil, fmt.Errorf("field %q[%d] resolution failed: %w", jsonKey, j, err) | |
| + } | |
| + val.Index(j).SetString(resolvedStr) | |
| + } | |
| + } | |
| + case reflect.Int, reflect.Bool, reflect.Int8, reflect.Int16, reflect.Int32, | |
| + reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, | |
| + reflect.Uint64, reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128: | |
| + //already copied by clone, ignore | |
| + default: | |
| + panic2(fmt.Sprintf("BUG: dev-unhandled case for val.Kind() of %q", vk)) | |
| + } //switch | |
| + } | |
| + return resolved, nil | |
| +} | |
| + | |
| +// applyConfigChangesToStruct applies the key→value pairs from changes (as | |
| +// produced by json.Unmarshal into map[string]any) onto cfg using the json | |
| +// struct tags to locate each field. Only fields whose json tag appears in | |
| +// changes are touched; all others are left intact. | |
| +// Supported field kinds: string, int/int32/int64, uint/uint32/uint64, bool, | |
| +// []string. Any other kind returns an error. | |
| +func applyConfigChangesToStruct(cfg *Config, changes map[string]any) error { | |
| + v := reflect.ValueOf(cfg).Elem() | |
| + t := v.Type() | |
| + | |
| + // Build a reverse map: json-key → field index, for O(len(changes)) total | |
| + // work instead of O(len(changes)*len(fields)). | |
| + tagToIdx := make(map[string]int, t.NumField()) | |
| + for i := 0; i < t.NumField(); i++ { | |
| + tag := t.Field(i).Tag.Get("json") | |
| + if tag == "" || tag == "-" { | |
| + continue | |
| + } | |
| + key := strings.Split(tag, ",")[0] | |
| + tagToIdx[key] = i | |
| + } | |
| + | |
| + for jsonKey, rawVal := range changes { | |
| + idx, ok := tagToIdx[jsonKey] | |
| + if !ok { | |
| + return fmt.Errorf("applyConfigChangesToStruct: unknown config key %q", jsonKey) | |
| + } | |
| + fv := v.Field(idx) | |
| + | |
| + switch fv.Kind() { //nolint:exhaustive // we error on the unsupported ones | |
| + case reflect.String: | |
| + s, ok := rawVal.(string) | |
| + if !ok { | |
| + return fmt.Errorf("field %q: expected string, got %T", jsonKey, rawVal) | |
| + } | |
| + fv.SetString(s) | |
| + | |
| + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: | |
| + switch n := rawVal.(type) { | |
| + case float64: | |
| + fv.SetInt(int64(n)) | |
| + case int: | |
| + fv.SetInt(int64(n)) | |
| + case int64: | |
| + fv.SetInt(n) | |
| + default: | |
| + return fmt.Errorf("field %q: expected int, got %T", jsonKey, rawVal) | |
| + } | |
| + | |
| + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: | |
| + switch n := rawVal.(type) { | |
| + case float64: | |
| + if n < 0 { | |
| + return fmt.Errorf("field %q: negative value %v for unsigned field", jsonKey, n) | |
| + } | |
| + fv.SetUint(uint64(n)) | |
| + case uint64: | |
| + fv.SetUint(n) | |
| + default: | |
| + return fmt.Errorf("field %q: expected uint, got %T", jsonKey, rawVal) | |
| + } | |
| + | |
| + case reflect.Bool: | |
| + b, ok := rawVal.(bool) | |
| + if !ok { | |
| + return fmt.Errorf("field %q: expected bool, got %T", jsonKey, rawVal) | |
| + } | |
| + fv.SetBool(b) | |
| + | |
| + case reflect.Slice: | |
| + if fv.Type().Elem().Kind() != reflect.String { | |
| + return fmt.Errorf("field %q: non-string slice fields are not supported in config edits", jsonKey) | |
| + } | |
| + switch arr := rawVal.(type) { | |
| + case []any: | |
| + strs := make([]string, len(arr)) | |
| + for i, item := range arr { | |
| + s, ok := item.(string) | |
| + if !ok { | |
| + return fmt.Errorf("field %q[%d]: expected string element, got %T", jsonKey, i, item) | |
| + } | |
| + strs[i] = s | |
| + } | |
| + fv.Set(reflect.ValueOf(strs)) | |
| + case []string: | |
| + cp := make([]string, len(arr)) | |
| + copy(cp, arr) | |
| + fv.Set(reflect.ValueOf(cp)) | |
| + default: | |
| + return fmt.Errorf("field %q: expected []string, got %T", jsonKey, rawVal) | |
| + } | |
| + | |
| + default: | |
| + panic2(fmt.Sprintf("field %q: unsupported kind %s", jsonKey, fv.Kind())) //yeah we panic instead! | |
| + return fmt.Errorf("field %q: unsupported kind %s", jsonKey, fv.Kind()) | |
| + } | |
| + } | |
| + return nil | |
| +} | |
| + | |
| +func (s *Server) getRawConfig() *Config { | |
| + if c := s.liveRawConfig.Load(); c != nil { | |
| + return c | |
| + } | |
| + panic2("BUG: Server.liveRawConfig not initialized before use") | |
| + panic(nil) | |
| +} | |
| + | |
| +func (ui *AdminUI) getRawConfig() *Config { | |
| + if ui.liveRawConfig != nil { | |
| + if c := ui.liveRawConfig.Load(); c != nil { | |
| + return c | |
| + } | |
| + panic2("BUG: AdminUI.liveRawConfig.Config isn't inited, should point to the Server.liveRawConfig.Config") | |
| + } | |
| + //// Fallback for test environments that only wire liveConfig. | |
| + // ui.getLogger().Warn("If this isn't in a test file, then BUG: liveRawConfig isn't inited, FIXME: fix the tests and remove this!") | |
| + // return ui.getConfig() | |
| + panic2("BUG: AdminUI.liveRawConfig isn't inited, should point to the Server.liveRawConfig") | |
| + panic(nil) | |
| +} | |
| + | |
| +// sanitizeAndValidateConfig handles validation, clamping, and cleaning of configuration fields. | |
| +// It is used by both loadMainConfig (on disk load) and configHandler (on WebUI apply) to ensure | |
| +// identical constraint enforcement and normalization. | |
| +func sanitizeAndValidateConfig(log *slog.Logger, resolvedCfg, rawCfg, defaultCfg *Config, isWebUI bool) (bool, error) { | |
| + var shouldSaveConfig bool | |
| + | |
| + tagWebUIUseTLS := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUIUseTLS)) | |
| + tagWebUIForceTLS := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUIForceTLSOnNonLocalhost)) | |
| + tagListenUI := getJSONTagByOffset(unsafe.Offsetof(Config{}.ListenUI)) | |
| + | |
| + boundToLoopback := isLoopbackBindHost(resolvedCfg.ListenUI) | |
| + | |
| + switch { | |
| + case !resolvedCfg.WebUIUseTLS && !boundToLoopback && resolvedCfg.WebUIForceTLSOnNonLocalhost: | |
| + log.Warn(tagWebUIUseTLS+" was false while "+tagListenUI+" is bound off-loopback; "+ | |
| + "auto-promoting to TLS so the bcrypt-checked WebUI password isn't sent as plaintext(thus sniffable) "+ | |
| + "Basic-Auth over the network. Set "+tagWebUIForceTLS+" to false to override.", | |
| + slog.String("listen_ui", resolvedCfg.ListenUI)) | |
| + resolvedCfg.WebUIUseTLS = true | |
| + rawCfg.WebUIUseTLS = true | |
| + shouldSaveConfig = true //hmm, self-heals?! | |
| + | |
| + case !resolvedCfg.WebUIUseTLS && !boundToLoopback: | |
| + log.Error(tagWebUIUseTLS+" and "+tagWebUIForceTLS+" are both false while bound off-loopback; "+ | |
| + "the WebUI password will be sent in PLAINTEXT (Basic-Auth is base64, not encryption) "+ | |
| + "to anyone who can observe this network segment.", | |
| + slog.String("listen_ui", resolvedCfg.ListenUI)) | |
| + | |
| + case !resolvedCfg.WebUIUseTLS && boundToLoopback: | |
| + log.Warn(tagWebUIUseTLS+" is false. Even on loopback, Basic-Auth sends the password as base64 "+ | |
| + "(not encrypted) to any other local process/user that can observe loopback traffic.", | |
| + slog.String("listen_ui", resolvedCfg.ListenUI)) | |
| + } | |
| + | |
| + // ========================================================================= | |
| + // Group 1: WebUI Server Timeouts & Rate Limits | |
| + // ========================================================================= | |
| + tagWebUIReadHeader := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUIReadHeaderTimeoutSec)) | |
| + if was := resolvedCfg.WebUIReadHeaderTimeoutSec; was <= 0 { | |
| + fallback := defaultCfg.WebUIReadHeaderTimeoutSec | |
| + resolvedCfg.WebUIReadHeaderTimeoutSec = fallback | |
| + rawCfg.WebUIReadHeaderTimeoutSec = fallback | |
| + log.Warn(tagWebUIReadHeader+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagWebUIRead := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUIReadTimeoutSec)) | |
| + if was := resolvedCfg.WebUIReadTimeoutSec; was <= 0 { | |
| + fallback := defaultCfg.WebUIReadTimeoutSec | |
| + resolvedCfg.WebUIReadTimeoutSec = fallback | |
| + rawCfg.WebUIReadTimeoutSec = fallback | |
| + log.Warn(tagWebUIRead+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagWebUIWrite := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUIWriteTimeoutSec)) | |
| + if was := resolvedCfg.WebUIWriteTimeoutSec; was <= 0 { | |
| + fallback := defaultCfg.WebUIWriteTimeoutSec | |
| + resolvedCfg.WebUIWriteTimeoutSec = fallback | |
| + rawCfg.WebUIWriteTimeoutSec = fallback | |
| + log.Warn(tagWebUIWrite+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagWebUIIdle := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUIIdleTimeoutSec)) | |
| + if was := resolvedCfg.WebUIIdleTimeoutSec; was <= resolvedCfg.WebUIReadTimeoutSec { | |
| + fallback := resolvedCfg.WebUIReadTimeoutSec * 2 | |
| + resolvedCfg.WebUIIdleTimeoutSec = fallback | |
| + rawCfg.WebUIIdleTimeoutSec = fallback | |
| + log.Warn(tagWebUIIdle+" clamped(to double the read timeout) to prevent aggressive keep-alive disconnects", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagWebUIMaxLoginFailures := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUIMaxLoginFailures)) | |
| + if was := resolvedCfg.WebUIMaxLoginFailures; was <= 0 { | |
| + fallback := defaultCfg.WebUIMaxLoginFailures | |
| + resolvedCfg.WebUIMaxLoginFailures = fallback | |
| + rawCfg.WebUIMaxLoginFailures = fallback | |
| + log.Warn(tagWebUIMaxLoginFailures+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagWebUILoginLockoutSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.WebUILoginLockoutSec)) | |
| + if was := resolvedCfg.WebUILoginLockoutSec; was <= 0 { | |
| + fallback := defaultCfg.WebUILoginLockoutSec | |
| + resolvedCfg.WebUILoginLockoutSec = fallback | |
| + rawCfg.WebUILoginLockoutSec = fallback | |
| + log.Warn(tagWebUILoginLockoutSec+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + // ========================================================================= | |
| + // Group 2: Local DoH Server Timeouts | |
| + // ========================================================================= | |
| + tagDoHHeader := getJSONTagByOffset(unsafe.Offsetof(Config{}.LocalDoHReadHeaderTimeoutSec)) | |
| + if was := resolvedCfg.LocalDoHReadHeaderTimeoutSec; was <= 0 { | |
| + fallback := defaultCfg.LocalDoHReadHeaderTimeoutSec | |
| + resolvedCfg.LocalDoHReadHeaderTimeoutSec = fallback | |
| + rawCfg.LocalDoHReadHeaderTimeoutSec = fallback | |
| + log.Warn(tagDoHHeader+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagDoHRead := getJSONTagByOffset(unsafe.Offsetof(Config{}.LocalDoHReadTimeoutSec)) | |
| + if was := resolvedCfg.LocalDoHReadTimeoutSec; was <= 0 { | |
| + fallback := defaultCfg.LocalDoHReadTimeoutSec | |
| + resolvedCfg.LocalDoHReadTimeoutSec = fallback | |
| + rawCfg.LocalDoHReadTimeoutSec = fallback | |
| + log.Warn(tagDoHRead+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagDoHWrite := getJSONTagByOffset(unsafe.Offsetof(Config{}.LocalDoHWriteTimeoutSec)) | |
| + if was := resolvedCfg.LocalDoHWriteTimeoutSec; was <= 0 { | |
| + fallback := defaultCfg.LocalDoHWriteTimeoutSec | |
| + resolvedCfg.LocalDoHWriteTimeoutSec = fallback | |
| + rawCfg.LocalDoHWriteTimeoutSec = fallback | |
| + log.Warn(tagDoHWrite+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagDoHIdle := getJSONTagByOffset(unsafe.Offsetof(Config{}.LocalDoHIdleTimeoutSec)) | |
| + if was := resolvedCfg.LocalDoHIdleTimeoutSec; was <= resolvedCfg.LocalDoHReadTimeoutSec { | |
| + fallback := resolvedCfg.LocalDoHReadTimeoutSec * 2 | |
| + resolvedCfg.LocalDoHIdleTimeoutSec = fallback | |
| + rawCfg.LocalDoHIdleTimeoutSec = fallback | |
| + log.Warn(tagDoHIdle+" clamped(to double the read timeout) to prevent premature keep-alive drops", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + // ========================================================================= | |
| + // Group 3: Upstream Client & Connection Pools | |
| + // ========================================================================= | |
| + tagUpstreamDialTimeoutSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.UpstreamDialTimeoutSec)) | |
| + if was := resolvedCfg.UpstreamDialTimeoutSec; was <= 0 { | |
| + fallback := defaultCfg.UpstreamDialTimeoutSec | |
| + resolvedCfg.UpstreamDialTimeoutSec = fallback | |
| + rawCfg.UpstreamDialTimeoutSec = fallback | |
| + log.Warn(tagUpstreamDialTimeoutSec+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagUpstreamClientTimeoutSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.UpstreamClientTimeoutSec)) | |
| + // Constraint A: Absolute lower bound check | |
| + if was := resolvedCfg.UpstreamClientTimeoutSec; was <= 0 { | |
| + fallback := defaultCfg.UpstreamClientTimeoutSec | |
| + resolvedCfg.UpstreamClientTimeoutSec = fallback | |
| + rawCfg.UpstreamClientTimeoutSec = fallback | |
| + log.Warn(tagUpstreamClientTimeoutSec+" clamped (prevents infinite hanging client connections)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + // Constraint B: Relational validation check (Sequential, never an 'else if') | |
| + if was := resolvedCfg.UpstreamClientTimeoutSec; was < resolvedCfg.UpstreamDialTimeoutSec { | |
| + fallback := resolvedCfg.UpstreamDialTimeoutSec | |
| + resolvedCfg.UpstreamClientTimeoutSec = fallback | |
| + rawCfg.UpstreamClientTimeoutSec = fallback | |
| + log.Warn(tagUpstreamClientTimeoutSec+" clamped (cannot be less than dial timeout "+tagUpstreamDialTimeoutSec+")", | |
| + slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagCertLogTimeoutSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.CertLogTimeoutSec)) | |
| + if was := resolvedCfg.CertLogTimeoutSec; was <= 0 { | |
| + fallback := defaultCfg.CertLogTimeoutSec | |
| + resolvedCfg.CertLogTimeoutSec = fallback | |
| + rawCfg.CertLogTimeoutSec = fallback | |
| + log.Warn(tagCertLogTimeoutSec+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagUpstreamRetryBackoffMs := getJSONTagByOffset(unsafe.Offsetof(Config{}.UpstreamRetryBackoffMs)) | |
| + if was := resolvedCfg.UpstreamRetryBackoffMs; was <= 0 { | |
| + fallback := defaultCfg.UpstreamRetryBackoffMs | |
| + resolvedCfg.UpstreamRetryBackoffMs = fallback | |
| + rawCfg.UpstreamRetryBackoffMs = fallback | |
| + log.Warn(tagUpstreamRetryBackoffMs+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagUpstreamRetriesPerQuery := getJSONTagByOffset(unsafe.Offsetof(Config{}.UpstreamRetriesPerQuery)) | |
| + if was := resolvedCfg.UpstreamRetriesPerQuery; was < 0 { | |
| + fallback := defaultCfg.UpstreamRetriesPerQuery | |
| + resolvedCfg.UpstreamRetriesPerQuery = fallback | |
| + rawCfg.UpstreamRetriesPerQuery = fallback | |
| + log.Warn(tagUpstreamRetriesPerQuery+" clamped (cannot be negative)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagUpstreamIdleConnTimeout := getJSONTagByOffset(unsafe.Offsetof(Config{}.UpstreamIdleConnTimeoutSec)) | |
| + if was := resolvedCfg.UpstreamIdleConnTimeoutSec; was <= 0 { | |
| + fallback := defaultCfg.UpstreamIdleConnTimeoutSec | |
| + resolvedCfg.UpstreamIdleConnTimeoutSec = fallback | |
| + rawCfg.UpstreamIdleConnTimeoutSec = fallback | |
| + log.Warn(tagUpstreamIdleConnTimeout+" clamped (connections stay open indefinitely or drop unpredictably)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagUpstreamMaxIdleConns := getJSONTagByOffset(unsafe.Offsetof(Config{}.UpstreamMaxIdleConns)) | |
| + if was := resolvedCfg.UpstreamMaxIdleConns; was <= 0 { | |
| + fallback := defaultCfg.UpstreamMaxIdleConns | |
| + resolvedCfg.UpstreamMaxIdleConns = fallback | |
| + rawCfg.UpstreamMaxIdleConns = fallback | |
| + log.Warn(tagUpstreamMaxIdleConns+" clamped (disables global keep-alive reuse)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagUpstreamMaxIdleConnsPerHost := getJSONTagByOffset(unsafe.Offsetof(Config{}.UpstreamMaxIdleConnsPerHost)) | |
| + // Constraint A: Absolute lower bound check | |
| + if was := resolvedCfg.UpstreamMaxIdleConnsPerHost; was <= 0 { | |
| + fallback := defaultCfg.UpstreamMaxIdleConnsPerHost | |
| + resolvedCfg.UpstreamMaxIdleConnsPerHost = fallback | |
| + rawCfg.UpstreamMaxIdleConnsPerHost = fallback | |
| + log.Warn(tagUpstreamMaxIdleConnsPerHost+" clamped (Go default of 2 severely throttles throughput)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + // Constraint B: Relational validation check (Sequential, never an 'else if') | |
| + if was := resolvedCfg.UpstreamMaxIdleConnsPerHost; was > resolvedCfg.UpstreamMaxIdleConns { | |
| + // Defensive check: Per-host pool limit can't realistically exceed global pool limit | |
| + fallback := resolvedCfg.UpstreamMaxIdleConns | |
| + resolvedCfg.UpstreamMaxIdleConnsPerHost = fallback | |
| + rawCfg.UpstreamMaxIdleConnsPerHost = fallback | |
| + log.Warn(tagUpstreamMaxIdleConnsPerHost+" clamped (cannot exceed "+tagUpstreamMaxIdleConns+")", | |
| + slog.Int("was", was), | |
| + slog.Int("clamp", fallback), | |
| + slog.Int(tagUpstreamMaxIdleConns, resolvedCfg.UpstreamMaxIdleConns), | |
| + ) | |
| + } | |
| + | |
| + // ========================================================================= | |
| + // Group 4: Local Client & Server Buffer Safeguards | |
| + // ========================================================================= | |
| + tagMaxConcurrentDNSTCPConns := getJSONTagByOffset(unsafe.Offsetof(Config{}.MaxConcurrentDNSTCPConns)) | |
| + if was := resolvedCfg.MaxConcurrentDNSTCPConns; was <= 0 { | |
| + fallback := defaultCfg.MaxConcurrentDNSTCPConns | |
| + resolvedCfg.MaxConcurrentDNSTCPConns = fallback | |
| + rawCfg.MaxConcurrentDNSTCPConns = fallback | |
| + log.Warn(tagMaxConcurrentDNSTCPConns+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + | |
| + tagMaxConcurrentDNSUDPQueries := getJSONTagByOffset(unsafe.Offsetof(Config{}.MaxConcurrentDNSUDPQueries)) | |
| + if was := resolvedCfg.MaxConcurrentDNSUDPQueries; was <= 0 { | |
| + fallback := defaultCfg.MaxConcurrentDNSUDPQueries | |
| + resolvedCfg.MaxConcurrentDNSUDPQueries = fallback | |
| + rawCfg.MaxConcurrentDNSUDPQueries = fallback | |
| + log.Warn(tagMaxConcurrentDNSUDPQueries+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| } | |
| - syncErr := truncFile.Sync() // Ensure the 0-byte state hits disk | |
| - closeErr := truncFile.Close() | |
| + tagClientTCPTimeoutSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.ClientTCPTimeoutSec)) | |
| + if was := resolvedCfg.ClientTCPTimeoutSec; was <= 0 { | |
| + fallback := defaultCfg.ClientTCPTimeoutSec | |
| + resolvedCfg.ClientTCPTimeoutSec = fallback | |
| + rawCfg.ClientTCPTimeoutSec = fallback | |
| + log.Warn(tagClientTCPTimeoutSec+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| - if syncErr != nil && closeErr != nil { | |
| - return fmt.Errorf("sync after truncate failed: %w (close also failed: %w)", syncErr, closeErr) | |
| + tagDoHMaxRequestBodyBytes := getJSONTagByOffset(unsafe.Offsetof(Config{}.DoHMaxRequestBodyBytes)) | |
| + if was := resolvedCfg.DoHMaxRequestBodyBytes; was <= 0 { | |
| + fallback := defaultCfg.DoHMaxRequestBodyBytes | |
| + resolvedCfg.DoHMaxRequestBodyBytes = fallback | |
| + rawCfg.DoHMaxRequestBodyBytes = fallback | |
| + log.Warn(tagDoHMaxRequestBodyBytes+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| } | |
| - if syncErr != nil { | |
| - return fmt.Errorf("sync after truncate failed: %w", syncErr) | |
| + | |
| + tagDNSUDPBufferSize := getJSONTagByOffset(unsafe.Offsetof(Config{}.DNSUDPBufferSize)) | |
| + if was := resolvedCfg.DNSUDPBufferSize; was < 512 || was > 65535 { | |
| + fallback := defaultCfg.DNSUDPBufferSize | |
| + resolvedCfg.DNSUDPBufferSize = fallback | |
| + rawCfg.DNSUDPBufferSize = fallback | |
| + log.Warn(tagDNSUDPBufferSize+" clamped (must be within standard Ethernet bounds 512-65535)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| } | |
| - if closeErr != nil { | |
| - return fmt.Errorf("close after truncate+sync failed: %w", closeErr) | |
| + | |
| + // ========================================================================= | |
| + // Group 5: Core Engine Limits & Cache Operations | |
| + // ========================================================================= | |
| + tagGlobalRateQPS := getJSONTagByOffset(unsafe.Offsetof(Config{}.GlobalRateQPS)) | |
| + if was := resolvedCfg.GlobalRateQPS; was <= 0 { | |
| + fallback := defaultCfg.GlobalRateQPS | |
| + resolvedCfg.GlobalRateQPS = fallback | |
| + rawCfg.GlobalRateQPS = fallback | |
| + log.Warn(tagGlobalRateQPS+" clamped (must be greater than 0)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| } | |
| - return nil | |
| -} | |
| + tagGlobalBurstQPS := getJSONTagByOffset(unsafe.Offsetof(Config{}.GlobalBurstQPS)) | |
| + // Constraint A: Absolute lower bound check | |
| + if was := resolvedCfg.GlobalBurstQPS; was <= 0 { | |
| + fallback := defaultCfg.GlobalBurstQPS | |
| + resolvedCfg.GlobalBurstQPS = fallback | |
| + rawCfg.GlobalBurstQPS = fallback | |
| + log.Warn(tagGlobalBurstQPS+" clamped (must be greater than 0)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| + // Constraint B: Relational check (Executed sequentially, NEVER as an 'else if') | |
| + if was := resolvedCfg.GlobalBurstQPS; was < resolvedCfg.GlobalRateQPS { | |
| + fallback := resolvedCfg.GlobalRateQPS | |
| + resolvedCfg.GlobalBurstQPS = fallback | |
| + rawCfg.GlobalBurstQPS = fallback | |
| + log.Warn(tagGlobalBurstQPS+" clamped (cannot be less than "+tagGlobalRateQPS+")", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| -// writeSyncedFile opens filename with the given flags, writes data, syncs, | |
| -// and closes as a single retryable unit. A mid-write failure from a | |
| -// transient Windows file lock is exactly as retryable as an open failure, | |
| -// so this covers both together rather than only guarding OpenFile. | |
| -func writeSyncedFile(filename string, data []byte, flags int, perm os.FileMode) error { | |
| - f, err := os.OpenFile(filename, flags, perm) | |
| - if err != nil { | |
| - return fmt.Errorf("open failed: %w", err) | |
| + tagClientRateQPS := getJSONTagByOffset(unsafe.Offsetof(Config{}.ClientRateQPS)) | |
| + if was := resolvedCfg.ClientRateQPS; was <= 0 { | |
| + fallback := defaultCfg.ClientRateQPS | |
| + resolvedCfg.ClientRateQPS = fallback | |
| + rawCfg.ClientRateQPS = fallback | |
| + log.Warn(tagClientRateQPS+" clamped (must be greater than 0)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| } | |
| - n, writeErr := f.Write(data) | |
| - syncErr := f.Sync() | |
| - closeErr := f.Close() | |
| - if writeErr != nil { | |
| - return fmt.Errorf("write failed after %d/%d bytes: %w", n, len(data), writeErr) | |
| + tagClientBurstQPS := getJSONTagByOffset(unsafe.Offsetof(Config{}.ClientBurstQPS)) | |
| + // Constraint A: Absolute lower bound check | |
| + if was := resolvedCfg.ClientBurstQPS; was <= 0 { | |
| + fallback := defaultCfg.ClientBurstQPS | |
| + resolvedCfg.ClientBurstQPS = fallback | |
| + rawCfg.ClientBurstQPS = fallback | |
| + log.Warn(tagClientBurstQPS+" clamped (must be greater than 0)", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| } | |
| - if syncErr != nil { | |
| - return fmt.Errorf("sync failed: %w", syncErr) | |
| + // Constraint B: Relational check (Executed sequentially, NEVER as an 'else if') | |
| + if was := resolvedCfg.ClientBurstQPS; was < resolvedCfg.ClientRateQPS { | |
| + fallback := resolvedCfg.ClientRateQPS | |
| + resolvedCfg.ClientBurstQPS = fallback | |
| + rawCfg.ClientBurstQPS = fallback | |
| + log.Warn(tagClientBurstQPS+" clamped (cannot be less than "+tagClientRateQPS+")", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| } | |
| - if closeErr != nil { | |
| - return fmt.Errorf("close failed: %w", closeErr) | |
| + | |
| + tagCacheMinTTL := getJSONTagByOffset(unsafe.Offsetof(Config{}.CacheMinTTL)) | |
| + if was := resolvedCfg.CacheMinTTL; was < cacheMinTTLClamp { | |
| + resolvedCfg.CacheMinTTL = cacheMinTTLClamp | |
| + rawCfg.CacheMinTTL = cacheMinTTLClamp | |
| + log.Warn(tagCacheMinTTL+" clamped to safe minimum", slog.Int("was", was), slog.Int("clamp", cacheMinTTLClamp)) | |
| } | |
| - return nil | |
| -} | |
| -var ( | |
| - kernel32 = windows.NewLazySystemDLL("kernel32.dll") | |
| - procSetConsoleCtrlHandler = kernel32.NewProc("SetConsoleCtrlHandler") | |
| + tagCacheMaxEntries := getJSONTagByOffset(unsafe.Offsetof(Config{}.CacheMaxEntries)) | |
| + if was := resolvedCfg.CacheMaxEntries; was <= 0 { | |
| + fallback := defaultCfg.CacheMaxEntries | |
| + resolvedCfg.CacheMaxEntries = fallback | |
| + rawCfg.CacheMaxEntries = fallback | |
| + log.Warn(tagCacheMaxEntries+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| - // Global bridge so our Win32 callback can reach your Server instance | |
| - globalConsoleEventTrigger func(eventName string) | |
| + tagCacheJanitorIntervalMinutes := getJSONTagByOffset(unsafe.Offsetof(Config{}.CacheJanitorIntervalMinutes)) | |
| + if was := resolvedCfg.CacheJanitorIntervalMinutes; was <= 0 { | |
| + fallback := defaultCfg.CacheJanitorIntervalMinutes | |
| + resolvedCfg.CacheJanitorIntervalMinutes = fallback | |
| + rawCfg.CacheJanitorIntervalMinutes = fallback | |
| + log.Warn(tagCacheJanitorIntervalMinutes+" clamped to safe minimum interval", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| - // NEW: Flag to bypass the "Press any key" pause during forced teardowns | |
| - skipInteractivePause atomic.Bool | |
| -) | |
| + tagCacheNegativeTTLSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.CacheNegativeTTLSec)) | |
| + if was := resolvedCfg.CacheNegativeTTLSec; was < 0 { | |
| + fallback := defaultCfg.CacheNegativeTTLSec | |
| + resolvedCfg.CacheNegativeTTLSec = fallback | |
| + rawCfg.CacheNegativeTTLSec = fallback | |
| + log.Warn(tagCacheNegativeTTLSec+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| -// consoleCtrlHandler must be a top-level function with no free variables for windows.NewCallback() | |
| -func consoleCtrlHandler(ctrlType uint32) uintptr { | |
| - const ( | |
| - CtrlCEvent = windows.CTRL_C_EVENT //0 | |
| - CtrlBreakEvent = windows.CTRL_BREAK_EVENT //1 | |
| - CtrlCloseEvent = windows.CTRL_CLOSE_EVENT //2 | |
| - CtrlLogoffEvent = windows.CTRL_LOGOFF_EVENT //5 | |
| - CtrlShutdownEvent = windows.CTRL_SHUTDOWN_EVENT //6 | |
| - ) | |
| + tagBlockedResponseTTLSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.BlockedResponseTTLSec)) | |
| + if was := resolvedCfg.BlockedResponseTTLSec; was < 0 { | |
| + fallback := defaultCfg.BlockedResponseTTLSec | |
| + resolvedCfg.BlockedResponseTTLSec = fallback | |
| + rawCfg.BlockedResponseTTLSec = fallback | |
| + log.Warn(tagBlockedResponseTTLSec+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| - var eventName string | |
| - switch ctrlType { | |
| - case CtrlCEvent: | |
| - eventName = "CTRL_C_EVENT (Ctrl+C)" //it's the sigChan one that triggers tho (this one does only while in shutdown()) | |
| - case CtrlBreakEvent: | |
| - eventName = "CTRL_BREAK_EVENT (Ctrl+Break)" | |
| - case CtrlCloseEvent: | |
| - skipInteractivePause.Store(true) // <-- Bypass pause! Console is closing. | |
| - eventName = "CTRL_CLOSE_EVENT (Console Window Closed)" | |
| - case CtrlLogoffEvent: | |
| - skipInteractivePause.Store(true) // <-- Bypass pause! User is logging out. | |
| - eventName = "CTRL_LOGOFF_EVENT (User Logoff)" | |
| - case CtrlShutdownEvent: | |
| - skipInteractivePause.Store(true) // <-- Bypass pause! OS is shutting down. | |
| - eventName = "CTRL_SHUTDOWN_EVENT (System Shutdown)" | |
| - default: | |
| - // Return 0 (FALSE) for unhandled events so Windows continues standard routing | |
| - return 0 | |
| + tagLocalHostsOverrideTTLSec := getJSONTagByOffset(unsafe.Offsetof(Config{}.LocalHostsOverrideTTLSec)) | |
| + if was := resolvedCfg.LocalHostsOverrideTTLSec; was == 0 { | |
| + fallback := defaultCfg.LocalHostsOverrideTTLSec | |
| + resolvedCfg.LocalHostsOverrideTTLSec = fallback | |
| + rawCfg.LocalHostsOverrideTTLSec = fallback | |
| + log.Warn(tagLocalHostsOverrideTTLSec+" clamped", slog.Uint64("was", uint64(was)), slog.Uint64("clamp", uint64(fallback))) | |
| } | |
| - if globalConsoleEventTrigger != nil { | |
| - // This will block, eventually calling os.Exit() from inside your shutdown sequence. | |
| - // This is required. If we returned 1 immediately, the OS would kill the process mid-cleanup. | |
| - globalConsoleEventTrigger(eventName) | |
| + tagMaxRecentBlocks := getJSONTagByOffset(unsafe.Offsetof(Config{}.MaxRecentBlocks)) | |
| + if was := resolvedCfg.MaxRecentBlocks; was <= 0 { | |
| + fallback := defaultCfg.MaxRecentBlocks | |
| + resolvedCfg.MaxRecentBlocks = fallback | |
| + rawCfg.MaxRecentBlocks = fallback | |
| + log.Warn(tagMaxRecentBlocks+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| } | |
| - return 1 // TRUE (Though os.Exit will usually fire before we ever reach this line) | |
| -} | |
| + tagUILogMaxLines := getJSONTagByOffset(unsafe.Offsetof(Config{}.UILogMaxLines)) | |
| + if was := resolvedCfg.UILogMaxLines; was <= 0 { | |
| + fallback := defaultCfg.UILogMaxLines | |
| + resolvedCfg.UILogMaxLines = fallback | |
| + rawCfg.UILogMaxLines = fallback | |
| + log.Warn(tagUILogMaxLines+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| -var reservedNames = map[string]struct{}{ | |
| - "CON": {}, "PRN": {}, "AUX": {}, "NUL": {}, | |
| - "COM1": {}, "COM2": {}, "COM3": {}, "COM4": {}, "COM5": {}, | |
| - "COM6": {}, "COM7": {}, "COM8": {}, "COM9": {}, | |
| - "LPT1": {}, "LPT2": {}, "LPT3": {}, "LPT4": {}, "LPT5": {}, | |
| - "LPT6": {}, "LPT7": {}, "LPT8": {}, "LPT9": {}, | |
| -} | |
| + tagLogMaxSizeMB := getJSONTagByOffset(unsafe.Offsetof(Config{}.LogMaxSizeMB)) | |
| + if was := resolvedCfg.LogMaxSizeMB; was <= 0 { | |
| + fallback := defaultCfg.LogMaxSizeMB | |
| + resolvedCfg.LogMaxSizeMB = fallback | |
| + rawCfg.LogMaxSizeMB = fallback | |
| + log.Warn(tagLogMaxSizeMB+" clamped", slog.Int("was", was), slog.Int("clamp", fallback)) | |
| + } | |
| -// resolveTag extracts the content of {file:filename} or {env:VAR}. | |
| -// It returns the resolved text, a boolean indicating if a tag was processed, and an error if it fails. | |
| -func resolveTag(input string) (resolved string, isTag bool, err error) { | |
| - input = strings.TrimSpace(input) | |
| + // ========================================================================= | |
| + // IP Strings Parsing & Post-Processing Operations | |
| + // ========================================================================= | |
| + // Clean up and pre-parse IPv4 | |
| + //TODO: do I have to set the parseds to rawCfg too?! | |
| + if ip := net.ParseIP(resolvedCfg.BlockIP); ip != nil && ip.To4() != nil { | |
| + resolvedCfg.BlockIPv4Parsed = ip.To4() | |
| + rawCfg.BlockIPv4Parsed = ip.To4() | |
| + } else { | |
| + const IP0 = "0.0.0.0" | |
| + const zero = 0 | |
| + resolvedCfg.BlockIP = IP0 | |
| + rawCfg.BlockIP = IP0 | |
| + resolvedCfg.BlockIPv4Parsed = net.IPv4(zero, zero, zero, zero).To4() | |
| + rawCfg.BlockIPv4Parsed = resolvedCfg.BlockIPv4Parsed // TODO: hopefully no need to have diff. instances of this here? | |
| + } | |
| - if strings.HasPrefix(input, "{file:") && strings.HasSuffix(input, "}") { | |
| - original := strings.TrimSpace(input[6 : len(input)-1]) | |
| + // Clean up and pre-parse IPv6 | |
| + if ip := net.ParseIP(resolvedCfg.BlockIPv6); ip != nil && ip.To16() != nil { | |
| + resolvedCfg.BlockIPv6Parsed = ip.To16() | |
| + rawCfg.BlockIPv6Parsed = ip.To16() // TODO: do we need this to be same instance for equals to say equals anywhere? | |
| + } else { | |
| + const IPv6Zero = "::" | |
| + resolvedCfg.BlockIPv6 = IPv6Zero | |
| + resolvedCfg.BlockIPv6Parsed = net.ParseIP(IPv6Zero).To16() | |
| + rawCfg.BlockIPv6Parsed = resolvedCfg.BlockIPv6Parsed // TODO: hopefully no need to have diff. instances of this here? | |
| + } | |
| - filename := strings.TrimRight(original, ". ") | |
| - filename = strings.ToUpper(filename) | |
| - // Strict directory traversal prevention | |
| - if strings.ContainsAny(filename, `/\:`) { | |
| - return "", true, fmt.Errorf("path separators not allowed in {file:...} — must be in same directory: %q", filename) | |
| - } | |
| - if _, bad := reservedNames[filename]; bad { | |
| - return "", true, fmt.Errorf("reserved Windows filename original: %q, processed:%q", original, filename) | |
| - } | |
| - configDir := filepath.Dir(configFileName) | |
| - path := filepath.Join(configDir, filename) //in same dir as config.json | |
| - data, err := os.ReadFile(path) | |
| - if err != nil { | |
| - return "", true, fmt.Errorf("failed to read external config file %q: %w", path, err) | |
| - } | |
| - // Trim trailing newlines commonly found in text files | |
| - return strings.TrimSpace(string(data)), true, nil | |
| + // Validate ListenDoH host is a literal IP (required for TLS cert SAN) | |
| + tagListenDoH := getJSONTagByOffset(unsafe.Offsetof(Config{}.ListenDoH)) | |
| + if doHHost, _, splitErr := net.SplitHostPort(resolvedCfg.ListenDoH); splitErr != nil { | |
| + return shouldSaveConfig, fmt.Errorf("%q %q is not a valid host:port, actually must be IP:port, err: %w", tagListenDoH, resolvedCfg.ListenDoH, splitErr) | |
| + } else if net.ParseIP(doHHost) == nil { | |
| + return shouldSaveConfig, fmt.Errorf("%q host %q must be an IP literal with no surrounding spaces (not a hostname(because we can't look it up without DNS)) for TLS cert generation", tagListenDoH, doHHost) | |
| + } | |
| + //tagListenUI := getJSONTagByOffset(unsafe.Offsetof(Config{}.ListenUI)) // dup | |
| + if uiHost, _, splitErr := net.SplitHostPort(resolvedCfg.ListenUI); splitErr != nil { | |
| + return shouldSaveConfig, fmt.Errorf("%q %q is not a valid host:port, actually must be IP:port, err: %w", tagListenUI, resolvedCfg.ListenUI, splitErr) | |
| + } else if net.ParseIP(uiHost) == nil { | |
| + return shouldSaveConfig, fmt.Errorf("%q host %q must be an IP literal with no surrounding spaces (not a hostname(because we can't look it up without DNS)) for TLS cert generation", tagListenUI, uiHost) | |
| } | |
| - if strings.HasPrefix(input, "{env:") && strings.HasSuffix(input, "}") { | |
| - envVar := strings.TrimSpace(input[5 : len(input)-1]) | |
| - val, ok := os.LookupEnv(envVar) | |
| - if !ok { | |
| - return "", true, fmt.Errorf("required environment variable %q is not set", envVar) | |
| - } | |
| - return strings.TrimSpace(val), true, nil | |
| - } | |
| + resolvedCfg.BlockMode = strings.ToLower(resolvedCfg.BlockMode) //XXX: lowercasing this for future comparisons to be easier! | |
| + rawCfg.BlockMode = resolvedCfg.BlockMode | |
| + //TODO: ensure only valid values are used here for config.BlockMode or warn/exit! | |
| - return input, false, nil | |
| -} | |
| + //TODO: see if I've to shouldSaveConfig for anything else here, above maybe? | |
| -// resolveConfigTags returns a deep-copied *Config with every {file:...} and | |
| -// {env:...} token in string and []string fields expanded to its real value. | |
| -// The input raw is never mutated; all changes live in the returned copy. | |
| -func resolveConfigTags(raw *Config) (*Config, error) { | |
| - if raw == nil { | |
| - return nil, errors.New("nil config") | |
| + // Hard-check URLs unconditionally to prevent downstream panics | |
| + for i, rawURL := range resolvedCfg.UpstreamURLs { | |
| + if _, err := url.Parse(rawURL); err != nil { | |
| + return shouldSaveConfig, fmt.Errorf("invalid upstream URL at index %d: %w", i, err) | |
| + } | |
| + if _, err := hostFromURL(rawURL); err != nil { | |
| + return shouldSaveConfig, fmt.Errorf("invalid upstream host at index %d: %w", i, err) | |
| + } | |
| } | |
| - resolved0 := raw.Clone() | |
| - resolved := &resolved0 | |
| - v := reflect.ValueOf(resolved).Elem() | |
| - t := v.Type() | |
| - | |
| - for i := 0; i < t.NumField(); i++ { | |
| - field := t.Field(i) | |
| - jsonTag := field.Tag.Get("json") | |
| - if jsonTag == "" || jsonTag == "-" { | |
| + if len(resolvedCfg.UpstreamSNIHostnames) > len(resolvedCfg.UpstreamURLs) { | |
| + const msg = "there are more SNIs vs URLs for upstream, only the opposite is allowed ( >= URLs than SNIs which then inherit the SNI from URLs)" | |
| + log.Warn(msg) | |
| + return shouldSaveConfig, fmt.Errorf("%s", msg) | |
| + } | |
| + // Ensure SNIHostnames has the same length as UpstreamURLs, falling back to the URL's hostname | |
| + for i := len(resolvedCfg.UpstreamSNIHostnames); i < len(resolvedCfg.UpstreamURLs); i++ { | |
| + host, err2 := hostFromURL(resolvedCfg.UpstreamURLs[i]) | |
| + if err2 != nil { | |
| + log.Warn("invalid upstream URL during SNI fill", slog.Int("index", i), SafeErr(err2)) | |
| + return shouldSaveConfig, fmt.Errorf("invalid upstream URL at index %d: %w", i, err2) | |
| + } | |
| + rawCfg.UpstreamSNIHostnames = append(rawCfg.UpstreamSNIHostnames, host) | |
| + resolvedCfg.UpstreamSNIHostnames = append(resolvedCfg.UpstreamSNIHostnames, host) | |
| + shouldSaveConfig = true | |
| + } | |
| + //FIXME: this is weird, what are we doing here below vs above?! | |
| + for i := range resolvedCfg.UpstreamURLs { | |
| + if resolvedCfg.UpstreamSNIHostnames[i] != "" { | |
| continue | |
| } | |
| - jsonKey := strings.Split(jsonTag, ",")[0] | |
| - val := v.Field(i) | |
| + host, err2 := hostFromURL(resolvedCfg.UpstreamURLs[i]) | |
| + if err2 != nil { | |
| + log.Error("invalid upstream URL", slog.Int("at_index", i), SafeErr(err2)) | |
| + return shouldSaveConfig, fmt.Errorf("invalid upstream URL at index %d: %w", i, err2) | |
| + } | |
| + rawCfg.UpstreamSNIHostnames[i] = host | |
| + resolvedCfg.UpstreamSNIHostnames[i] = host | |
| + shouldSaveConfig = true | |
| + } | |
| + log.Debug("Using upstream SNI hostnames:", | |
| + SafeStringSlice("SNI_hostnames", resolvedCfg.UpstreamSNIHostnames), | |
| + ) | |
| - //The reflection loop is not constructing a new Config. It's only modifying certain fields in place. | |
| - vk := val.Kind() | |
| - switch vk { //nolint:exhaustive //only handing the cases that we know are template-able | |
| - case reflect.String: | |
| - str := val.String() | |
| - resolvedStr, isTag, err := resolveTag(str) | |
| - if isTag { | |
| - if err != nil { | |
| - return nil, fmt.Errorf("field %q resolution failed: %w", jsonKey, err) | |
| + // Helper closure to apply the cleaning and track if a save is needed | |
| + checkAndClean := func(resolvedTarget, rawTarget *string, configKey, fallback string) error { | |
| + if cleaned, changed := cleanFileName(log, *resolvedTarget, configKey, fallback); changed { | |
| + if *resolvedTarget != *rawTarget { | |
| + errStr := fmt.Sprintf("Won't overwrite template %q with cleaned value %q, you must do it manually then rerun.", *rawTarget, cleaned) | |
| + if isWebUI { | |
| + return errors.New(errStr) | |
| + } else { | |
| + return errors.New("FATAL: " + errStr) | |
| } | |
| - val.SetString(resolvedStr) | |
| - } | |
| - case reflect.Slice: | |
| - if val.Type().Elem().Kind() != reflect.String { | |
| - panic2(fmt.Sprintf("BUG: dev-unhandled case for reflect.Slice that isn't string but it's %q", vk)) | |
| - continue | |
| } | |
| - for j := 0; j < val.Len(); j++ { | |
| - str := val.Index(j).String() | |
| - resolvedStr, isTag, err := resolveTag(str) | |
| - if isTag { | |
| - if err != nil { | |
| - return nil, fmt.Errorf("field %q[%d] resolution failed: %w", jsonKey, j, err) | |
| - } | |
| - val.Index(j).SetString(resolvedStr) | |
| - } | |
| + *resolvedTarget = cleaned | |
| + *rawTarget = cleaned | |
| + if !shouldSaveConfig { | |
| + shouldSaveConfig = true | |
| } | |
| - case reflect.Int, reflect.Bool, reflect.Int8, reflect.Int16, reflect.Int32, | |
| - reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, | |
| - reflect.Uint64, reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128: | |
| - //already copied by clone, ignore | |
| - default: | |
| - panic2(fmt.Sprintf("BUG: dev-unhandled case for val.Kind() of %q", vk)) | |
| - } //switch | |
| - } | |
| - return resolved, nil | |
| -} | |
| - | |
| -// applyConfigChangesToStruct applies the key→value pairs from changes (as | |
| -// produced by json.Unmarshal into map[string]any) onto cfg using the json | |
| -// struct tags to locate each field. Only fields whose json tag appears in | |
| -// changes are touched; all others are left intact. | |
| -// Supported field kinds: string, int/int32/int64, uint/uint32/uint64, bool, | |
| -// []string. Any other kind returns an error. | |
| -func applyConfigChangesToStruct(cfg *Config, changes map[string]any) error { | |
| - v := reflect.ValueOf(cfg).Elem() | |
| - t := v.Type() | |
| - | |
| - // Build a reverse map: json-key → field index, for O(len(changes)) total | |
| - // work instead of O(len(changes)*len(fields)). | |
| - tagToIdx := make(map[string]int, t.NumField()) | |
| - for i := 0; i < t.NumField(); i++ { | |
| - tag := t.Field(i).Tag.Get("json") | |
| - if tag == "" || tag == "-" { | |
| - continue | |
| } | |
| - key := strings.Split(tag, ",")[0] | |
| - tagToIdx[key] = i | |
| + return nil | |
| } | |
| - for jsonKey, rawVal := range changes { | |
| - idx, ok := tagToIdx[jsonKey] | |
| - if !ok { | |
| - return fmt.Errorf("applyConfigChangesToStruct: unknown config key %q", jsonKey) | |
| - } | |
| - fv := v.Field(idx) | |
| - | |
| - switch fv.Kind() { //nolint:exhaustive // we error on the unsupported ones | |
| - case reflect.String: | |
| - s, ok := rawVal.(string) | |
| - if !ok { | |
| - return fmt.Errorf("field %q: expected string, got %T", jsonKey, rawVal) | |
| - } | |
| - fv.SetString(s) | |
| - | |
| - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: | |
| - switch n := rawVal.(type) { | |
| - case float64: | |
| - fv.SetInt(int64(n)) | |
| - case int: | |
| - fv.SetInt(int64(n)) | |
| - case int64: | |
| - fv.SetInt(n) | |
| - default: | |
| - return fmt.Errorf("field %q: expected int, got %T", jsonKey, rawVal) | |
| - } | |
| + if err := checkAndClean(&resolvedCfg.BlacklistFile, &rawCfg.BlacklistFile, getJSONTagByOffset(unsafe.Offsetof(Config{}.BlacklistFile)), defaultCfg.BlacklistFile); err != nil { | |
| + return shouldSaveConfig, err | |
| + } | |
| + if err := checkAndClean(&resolvedCfg.WhitelistFile, &rawCfg.WhitelistFile, getJSONTagByOffset(unsafe.Offsetof(Config{}.WhitelistFile)), defaultCfg.WhitelistFile); err != nil { | |
| + return shouldSaveConfig, err | |
| + } | |
| + if err := checkAndClean(&resolvedCfg.LogQueriesFile, &rawCfg.LogQueriesFile, getJSONTagByOffset(unsafe.Offsetof(Config{}.LogQueriesFile)), defaultCfg.LogQueriesFile); err != nil { | |
| + return shouldSaveConfig, err | |
| + } | |
| + if err := checkAndClean(&resolvedCfg.LogEverythingFile, &rawCfg.LogEverythingFile, getJSONTagByOffset(unsafe.Offsetof(Config{}.LogEverythingFile)), defaultCfg.LogEverythingFile); err != nil { | |
| + return shouldSaveConfig, err | |
| + } | |
| + if err := checkAndClean(&resolvedCfg.HostsFile, &rawCfg.HostsFile, getJSONTagByOffset(unsafe.Offsetof(Config{}.HostsFile)), defaultCfg.HostsFile); err != nil { | |
| + return shouldSaveConfig, err | |
| + } | |
| - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: | |
| - switch n := rawVal.(type) { | |
| - case float64: | |
| - if n < 0 { | |
| - return fmt.Errorf("field %q: negative value %v for unsigned field", jsonKey, n) | |
| - } | |
| - fv.SetUint(uint64(n)) | |
| - case uint64: | |
| - fv.SetUint(n) | |
| - default: | |
| - return fmt.Errorf("field %q: expected uint, got %T", jsonKey, rawVal) | |
| - } | |
| + if resolvedCfg.WebUIPasswordHash == "" && isWebUI { | |
| + return shouldSaveConfig, errors.New("webui_password_hash cannot be empty") | |
| + } | |
| - case reflect.Bool: | |
| - b, ok := rawVal.(bool) | |
| - if !ok { | |
| - return fmt.Errorf("field %q: expected bool, got %T", jsonKey, rawVal) | |
| - } | |
| - fv.SetBool(b) | |
| + return shouldSaveConfig, nil | |
| +} | |
| - case reflect.Slice: | |
| - if fv.Type().Elem().Kind() != reflect.String { | |
| - return fmt.Errorf("field %q: non-string slice fields are not supported in config edits", jsonKey) | |
| - } | |
| - switch arr := rawVal.(type) { | |
| - case []any: | |
| - strs := make([]string, len(arr)) | |
| - for i, item := range arr { | |
| - s, ok := item.(string) | |
| - if !ok { | |
| - return fmt.Errorf("field %q[%d]: expected string element, got %T", jsonKey, i, item) | |
| - } | |
| - strs[i] = s | |
| - } | |
| - fv.Set(reflect.ValueOf(strs)) | |
| - case []string: | |
| - cp := make([]string, len(arr)) | |
| - copy(cp, arr) | |
| - fv.Set(reflect.ValueOf(cp)) | |
| - default: | |
| - return fmt.Errorf("field %q: expected []string, got %T", jsonKey, rawVal) | |
| - } | |
| +// cleanFileName returns the cleaned filename and a boolean indicating if the original was modified. | |
| +// //extracted this method to be a free function so the standalone logic can use it independently | |
| +func cleanFileName(log *slog.Logger, original, configKey, fallback string) (string, bool) { | |
| + if original == "" { | |
| + if fallback == "" { | |
| + msg := fmt.Sprintf("BUG: dev fail: passed empty filename to clean for config key %q and the fallback was also empty!", configKey) | |
| + log.Error(msg, slog.String("config_key", configKey)) | |
| + panic(msg) | |
| + } | |
| + log.Warn("Bad filename in config, used fallback", | |
| + slog.String("for_config_key", configKey), | |
| + slog.String("bad_filename", original), | |
| + slog.String("fallback_filename", fallback)) | |
| - default: | |
| - panic2(fmt.Sprintf("field %q: unsupported kind %s", jsonKey, fv.Kind())) //yeah we panic instead! | |
| - return fmt.Errorf("field %q: unsupported kind %s", jsonKey, fv.Kind()) | |
| + // Ensure the fallback itself is clean before returning | |
| + cleaned := filepath.Clean(fallback) //FIXME: not a fan of having to call Clean twice, for DRY purposes. | |
| + if cleaned != fallback { | |
| + msg := fmt.Sprintf("BUG: dev fail: fallback(%q) for config key %q had to be cleaned into %q", fallback, configKey, cleaned) | |
| + log.Error(msg, slog.String("config_key", configKey), slog.String("fallback_filename", fallback), slog.String("filename_cleaned", cleaned)) | |
| + panic(msg) | |
| } | |
| + return cleaned, true | |
| } | |
| - return nil | |
| -} | |
| -func (s *Server) getRawConfig() *Config { | |
| - if c := s.liveRawConfig.Load(); c != nil { | |
| - return c | |
| + cleaned := filepath.Clean(original) | |
| + // Reject Windows reserved device names (CON, NUL, COM1, etc.). | |
| + // filepath.Base handles any directory prefix; TrimRight strips trailing | |
| + // dots and spaces that Windows itself strips before resolving the name. | |
| + baseName := strings.ToUpper(strings.TrimRight(filepath.Base(cleaned), ". ")) | |
| + if _, reserved := reservedNames[baseName]; reserved { | |
| + log.Warn("Config filename is a reserved Windows device name; using fallback", | |
| + slog.String("for_config_key", configKey), | |
| + slog.String("reserved_filename", cleaned), | |
| + slog.String("fallback_filename", fallback)) | |
| + return filepath.Clean(fallback), true | |
| } | |
| - panic2("BUG: Server.liveRawConfig not initialized before use") | |
| - panic(nil) | |
| -} | |
| - | |
| -func (ui *AdminUI) getRawConfig() *Config { | |
| - if ui.liveRawConfig != nil { | |
| - if c := ui.liveRawConfig.Load(); c != nil { | |
| - return c | |
| - } | |
| - panic2("BUG: AdminUI.liveRawConfig.Config isn't inited, should point to the Server.liveRawConfig.Config") | |
| + if cleaned != original { | |
| + log.Debug("Cleaned filename from config file", | |
| + slog.String("config_key", configKey), | |
| + slog.String("filename_before", original), | |
| + slog.String("filename_after", cleaned)) | |
| + return cleaned, true | |
| } | |
| - //// Fallback for test environments that only wire liveConfig. | |
| - // ui.getLogger().Warn("If this isn't in a test file, then BUG: liveRawConfig isn't inited, FIXME: fix the tests and remove this!") | |
| - // return ui.getConfig() | |
| - panic2("BUG: AdminUI.liveRawConfig isn't inited, should point to the Server.liveRawConfig") | |
| - panic(nil) | |
| + | |
| + return original, false | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment