From 9833d3b7907b9a85854836a02d0f66ddb0f01e2c Mon Sep 17 00:00:00 2001 From: Stanislav Ilnytskyi Date: Thu, 26 Mar 2026 17:44:38 +0100 Subject: [PATCH 1/5] Add WSL Windows root CA trust support --- CHANGELOG.md | 1 + commands/install.cmd | 9 ++++++++ utils/install.sh | 52 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d197779..38cab74e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ **Bug Fixes:** * Add support to dynamically connect peered services based on enabled status ([#892](https://github.com/wardenenv/warden/issues/892) by @bap14, [#919](https://github.com/wardenenv/warden/issues/919) by @xinsodev) * Fix WARDEN_DOCKER_SOCK error running `warden sign-certificate` ([#907](https://github.com/wardenenv/warden/issues/907) by @bap14) +* Automatically trust the Warden root CA in the Windows CurrentUser Root store when `warden install` is run inside WSL, allowing Windows browsers to trust local Warden certificates without manual import ## Version [0.16.0](https://github.com/wardenenv/warden/tree/0.16.0) (2026-02-12) diff --git a/commands/install.cmd b/commands/install.cmd index 7f2486d2..829ebbf4 100644 --- a/commands/install.cmd +++ b/commands/install.cmd @@ -51,6 +51,15 @@ then -k /Library/Keychains/System.keychain "${WARDEN_SSL_DIR}/rootca/certs/ca.cert.pem" fi +if hasWindowsCertificateBridge; then + echo "==> Trusting root certificate in Windows CurrentUser store" + if ! windows_trust_status="$(trustRootCaInWindows "${WARDEN_SSL_DIR}/rootca/certs/ca.cert.pem")"; then + warning "Unable to trust the Warden root certificate in Windows. Windows browsers may continue to warn until it is imported manually." + elif [[ "${windows_trust_status}" == "imported" ]]; then + echo "==> Root certificate imported into Windows CurrentUser Root store" + fi +fi + ## configure resolver for .test domains on Mac OS only as Linux lacks support ## for BSD like per-TLD configuration as is done at /etc/resolver/test on Mac if [[ "$OSTYPE" == "darwin"* ]]; then diff --git a/utils/install.sh b/utils/install.sh index dc80e05a..9a87b28c 100644 --- a/utils/install.sh +++ b/utils/install.sh @@ -1,6 +1,58 @@ #!/usr/bin/env bash [[ ! ${WARDEN_DIR} ]] && >&2 echo -e "\033[31mThis script is not intended to be run directly!\033[0m" && exit 1 +function isWsl () { + [[ -n "${WSL_DISTRO_NAME:-}" ]] && return 0 + [[ -r /proc/sys/kernel/osrelease ]] && grep -qiE '(microsoft|wsl)' /proc/sys/kernel/osrelease && return 0 + [[ -r /proc/version ]] && grep -qiE '(microsoft|wsl)' /proc/version && return 0 + return 1 +} + +function hasWindowsCertificateBridge () { + isWsl && command -v powershell.exe >/dev/null 2>&1 +} + +function trustRootCaInWindows () { + local cert_path="${1}" + local windows_cert_path powershell_script trust_status + + [[ -f "${cert_path}" ]] || return 1 + + windows_cert_path="$(wslpath -w "${cert_path}")" || return 1 + windows_cert_path="${windows_cert_path//\'/\'\'}" + + read -r -d '' powershell_script <<-EOT || true + & { + \$certPath = '${windows_cert_path}' + if (-not (Test-Path \$certPath)) { + throw "Certificate path not found: \$certPath" + } + + \$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(\$certPath) + \$store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root', 'CurrentUser') + \$store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) + + try { + \$existing = \$store.Certificates | Where-Object { \$_.Thumbprint -eq \$cert.Thumbprint } + if (\$existing) { + Write-Output 'present' + return + } + + \$store.Add(\$cert) + Write-Output 'imported' + } finally { + \$store.Close() + } + } + EOT + + trust_status="$(powershell.exe -NoProfile -NonInteractive -Command "${powershell_script}" | tr -d '\r')" || return 1 + [[ "${trust_status}" =~ ^(present|imported)$ ]] || return 1 + + echo "${trust_status}" +} + function installSshConfig () { if ! grep '## WARDEN START ##' /etc/ssh/ssh_config >/dev/null; then echo "==> Configuring sshd tunnel in host ssh_config (requires sudo privileges)" From 55e891a2b5bb5f7de059e6db1f4f97d6a142ab91 Mon Sep 17 00:00:00 2001 From: Stanislav Ilnytskyi Date: Fri, 27 Mar 2026 10:24:28 +0100 Subject: [PATCH 2/5] Replace stale Windows Warden root certificates --- commands/install.cmd | 2 ++ utils/install.sh | 19 +++++++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/commands/install.cmd b/commands/install.cmd index 829ebbf4..e7dcae76 100644 --- a/commands/install.cmd +++ b/commands/install.cmd @@ -57,6 +57,8 @@ if hasWindowsCertificateBridge; then warning "Unable to trust the Warden root certificate in Windows. Windows browsers may continue to warn until it is imported manually." elif [[ "${windows_trust_status}" == "imported" ]]; then echo "==> Root certificate imported into Windows CurrentUser Root store" + elif [[ "${windows_trust_status}" == "replaced" ]]; then + echo "==> Root certificate replaced in Windows CurrentUser Root store" fi fi diff --git a/utils/install.sh b/utils/install.sh index 9a87b28c..2776ef81 100644 --- a/utils/install.sh +++ b/utils/install.sh @@ -39,8 +39,23 @@ function trustRootCaInWindows () { return } + \$staleWardenRoots = @( + \$store.Certificates | Where-Object { + \$_.Thumbprint -ne \$cert.Thumbprint -and + \$_.Subject -like '*O=Warden.dev*' -and + \$_.Subject -like '*CN=Warden Proxy Local CA*' + } + ) + foreach (\$staleCert in \$staleWardenRoots) { + \$store.Remove(\$staleCert) + } + \$store.Add(\$cert) - Write-Output 'imported' + if (\$staleWardenRoots.Count -gt 0) { + Write-Output 'replaced' + } else { + Write-Output 'imported' + } } finally { \$store.Close() } @@ -48,7 +63,7 @@ function trustRootCaInWindows () { EOT trust_status="$(powershell.exe -NoProfile -NonInteractive -Command "${powershell_script}" | tr -d '\r')" || return 1 - [[ "${trust_status}" =~ ^(present|imported)$ ]] || return 1 + [[ "${trust_status}" =~ ^(present|imported|replaced)$ ]] || return 1 echo "${trust_status}" } From 19fd35aa42b656f4789b1441cd843cfe73a20428 Mon Sep 17 00:00:00 2001 From: Stanislav Ilnytskyi Date: Fri, 27 Mar 2026 10:26:13 +0100 Subject: [PATCH 3/5] Add new Windows root before pruning stale certs --- utils/install.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/utils/install.sh b/utils/install.sh index 2776ef81..f63881d7 100644 --- a/utils/install.sh +++ b/utils/install.sh @@ -46,11 +46,10 @@ function trustRootCaInWindows () { \$_.Subject -like '*CN=Warden Proxy Local CA*' } ) + \$store.Add(\$cert) foreach (\$staleCert in \$staleWardenRoots) { \$store.Remove(\$staleCert) } - - \$store.Add(\$cert) if (\$staleWardenRoots.Count -gt 0) { Write-Output 'replaced' } else { From 1eb969e4162276dfd0a6c63e42f0c2fa3baa8028 Mon Sep 17 00:00:00 2001 From: Stanislav Ilnytskyi Date: Fri, 27 Mar 2026 11:14:49 +0100 Subject: [PATCH 4/5] Load shared install messaging helpers --- utils/install.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/utils/install.sh b/utils/install.sh index f63881d7..d84847f1 100644 --- a/utils/install.sh +++ b/utils/install.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash [[ ! ${WARDEN_DIR} ]] && >&2 echo -e "\033[31mThis script is not intended to be run directly!\033[0m" && exit 1 +source "${WARDEN_DIR}/utils/core.sh" + function isWsl () { [[ -n "${WSL_DISTRO_NAME:-}" ]] && return 0 [[ -r /proc/sys/kernel/osrelease ]] && grep -qiE '(microsoft|wsl)' /proc/sys/kernel/osrelease && return 0 From 7a13323a6224622ac38422efbe0b621f464f4a37 Mon Sep 17 00:00:00 2001 From: Stanislav Ilnytskyi Date: Sun, 29 Mar 2026 19:25:34 +0200 Subject: [PATCH 5/5] Codex/windows doh support (#1) * Add Windows DoH and Schannel-compatible cert support * Use doh hostname for Windows DoH endpoint * Auto-enable dnsmasq when DoH is enabled * Use default doh endpoint in changelog examples * Refine DNS-over-HTTPS PKI sidecar * Consolidate DNS-over-HTTPS compose services * Clarify DoH PKI validation comments * Clarify HTTP exception comment * decompose and simplify winows part * better WSL integration * improved windows bridge * typo --- CHANGELOG.md | 7 + commands/doctor.cmd | 281 +++++++++++++ commands/install.cmd | 31 +- commands/sign-certificate.cmd | 23 +- commands/svc.cmd | 41 ++ config/openssl/rootca.conf | 6 +- config/traefik/traefik.yml | 5 - docker/dns-over-https/docker-entrypoint.sh | 16 + docker/docker-compose.dns-over-https.yml | 49 +++ utils/install.sh | 68 +--- utils/windows/get-certificate-thumbprint.ps1 | 9 + utils/windows/get-doh-template-state.ps1 | 39 ++ utils/windows/get-managed-hosts-state.ps1 | 61 +++ utils/windows/get-root-store-state.ps1 | 36 ++ .../import-root-localmachine-elevated.ps1 | 96 +++++ utils/windows/import-root-localmachine.ps1 | 83 ++++ .../windows/install-doh-template-elevated.ps1 | 89 +++++ .../install-managed-hosts-elevated.ps1 | 143 +++++++ utils/windows/install.sh | 376 ++++++++++++++++++ utils/windows/show-notification.ps1 | 37 ++ utils/windows/test-dns-resolution.ps1 | 21 + utils/windows/trust-root-store.ps1 | 89 +++++ 22 files changed, 1515 insertions(+), 91 deletions(-) create mode 100755 docker/dns-over-https/docker-entrypoint.sh create mode 100644 docker/docker-compose.dns-over-https.yml create mode 100644 utils/windows/get-certificate-thumbprint.ps1 create mode 100644 utils/windows/get-doh-template-state.ps1 create mode 100644 utils/windows/get-managed-hosts-state.ps1 create mode 100644 utils/windows/get-root-store-state.ps1 create mode 100644 utils/windows/import-root-localmachine-elevated.ps1 create mode 100644 utils/windows/import-root-localmachine.ps1 create mode 100644 utils/windows/install-doh-template-elevated.ps1 create mode 100644 utils/windows/install-managed-hosts-elevated.ps1 create mode 100644 utils/windows/install.sh create mode 100644 utils/windows/show-notification.ps1 create mode 100644 utils/windows/test-dns-resolution.ps1 create mode 100644 utils/windows/trust-root-store.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 38cab74e..c090b945 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ * Add support to dynamically connect peered services based on enabled status ([#892](https://github.com/wardenenv/warden/issues/892) by @bap14, [#919](https://github.com/wardenenv/warden/issues/919) by @xinsodev) * Fix WARDEN_DOCKER_SOCK error running `warden sign-certificate` ([#907](https://github.com/wardenenv/warden/issues/907) by @bap14) * Automatically trust the Warden root CA in the Windows CurrentUser Root store when `warden install` is run inside WSL, allowing Windows browsers to trust local Warden certificates without manual import +* `warden install` now attempts to trust the Warden root CA in the Windows LocalMachine Root store first, falls back to CurrentUser Root when elevation or policy prevents it, and reports Windows root store state via `warden doctor` +* Warden-issued TLS certificates now include local CRL/AIA metadata and publish revocation artifacts on `http://127.0.0.1/.warden/pki/` so Windows Schannel can validate local HTTPS services such as native DoH + +**Enhancements:** +* Added optional DNS-over-HTTPS support for Windows / WSL workflows via `WARDEN_DNS_OVER_HTTPS_ENABLE=1`, serving `https://doh.warden.test/dns-query` by default +* Enabling `WARDEN_DNS_OVER_HTTPS_ENABLE=1` now automatically keeps Warden `dnsmasq` enabled for the same global services run because the DoH bridge depends on the existing DNS resolver +* Enabling `WARDEN_DNS_OVER_HTTPS_ENABLE=1` now starts a `dns-over-https-pki` sidecar that publishes the local CRL/AIA files Windows Schannel uses, without keeping that HTTP publisher active when DoH is disabled ## Version [0.16.0](https://github.com/wardenenv/warden/tree/0.16.0) (2026-02-12) diff --git a/commands/doctor.cmd b/commands/doctor.cmd index 7d3f9f52..9d6badc0 100644 --- a/commands/doctor.cmd +++ b/commands/doctor.cmd @@ -1,6 +1,8 @@ #!/usr/bin/env bash [[ ! ${WARDEN_DIR} ]] && >&2 echo -e "\033[31mThis script is not intended to be run directly!\033[0m" && exit 1 +source "${WARDEN_DIR}/utils/install.sh" + ## Disable immediate exit on failure (set in main warden bin), we use this to detect whether docker is running and continue. set +e @@ -43,6 +45,285 @@ echo -e "\033[32mWarden global .env:\033[0m" cat ${WARDEN_HOME_DIR}/.env echo +if [[ -f "${WARDEN_HOME_DIR}/.env" ]]; then + eval "$(grep "^WARDEN_SERVICE_DOMAIN" "${WARDEN_HOME_DIR}/.env")" + eval "$(grep "^WARDEN_DNS_OVER_HTTPS_ENABLE" "${WARDEN_HOME_DIR}/.env")" +fi +WARDEN_SERVICE_DOMAIN="${WARDEN_SERVICE_DOMAIN:-warden.test}" +WARDEN_DNS_OVER_HTTPS_ENABLE="${WARDEN_DNS_OVER_HTTPS_ENABLE:-0}" + +function probeHttpsUrl() { + local curl_bin="${1}" + local url="${2}" + local null_target="${3}" + local http_code_file stderr_file + local http_code stderr_output rc + + command -v "${curl_bin}" >/dev/null 2>&1 || { + echo "unavailable" + return 0 + } + + http_code_file="$(mktemp)" || return 1 + stderr_file="$(mktemp)" || { + rm -f "${http_code_file}" + return 1 + } + + "${curl_bin}" \ + --silent \ + --show-error \ + --output "${null_target}" \ + --write-out "%{http_code}" \ + --connect-timeout 5 \ + --max-time 10 \ + "${url}" >"${http_code_file}" 2>"${stderr_file}" + rc=$? + http_code="$(tr -d '\r\n' < "${http_code_file}")" + stderr_output="$(tr -d '\r' < "${stderr_file}")" + rm -f "${http_code_file}" "${stderr_file}" + + if [[ ${rc} -eq 0 ]] && [[ "${http_code}" =~ ^[0-9]{3}$ ]] && [[ "${http_code}" != "000" ]]; then + echo "reachable_${http_code}" + return 0 + fi + + if [[ "${stderr_output}" == *"Could not resolve host"* ]] || [[ "${stderr_output}" == *"could not be resolved"* ]]; then + echo "dns_failed" + elif [[ ${rc} -eq 35 ]] || [[ ${rc} -eq 51 ]] || [[ ${rc} -eq 58 ]] || [[ ${rc} -eq 59 ]] || [[ ${rc} -eq 60 ]] || [[ "${stderr_output}" == *"SSL"* ]] || [[ "${stderr_output}" == *"certificate"* ]] || [[ "${stderr_output}" == *"schannel"* ]]; then + echo "tls_failed" + elif [[ ${rc} -eq 7 ]] || [[ ${rc} -eq 28 ]] || [[ ${rc} -eq 52 ]] || [[ ${rc} -eq 56 ]] || [[ "${stderr_output}" == *"Failed to connect"* ]] || [[ "${stderr_output}" == *"Connection refused"* ]] || [[ "${stderr_output}" == *"timed out"* ]] || [[ "${stderr_output}" == *"forcibly closed"* ]]; then + echo "connect_failed" + else + echo "error_${rc}" + fi +} + +function formatHttpsProbeResult() { + local label="${1}" + local result="${2}" + local expected_code="${3:-}" + local actual_code + + case "${result}" in + reachable_*) + actual_code="${result#reachable_}" + if [[ -n "${expected_code}" ]] && [[ "${actual_code}" != "${expected_code}" ]]; then + echo -e "\033[33m${label}: reachable (${actual_code}, expected ${expected_code})\033[0m" + else + echo -e "\033[33m${label}: reachable (${actual_code})\033[0m" + fi + ;; + disabled) + echo -e "\033[33m${label}: disabled\033[0m" + ;; + unavailable) + echo -e "\033[33m${label}: unavailable\033[0m" + ;; + dns_failed) + echo -e "\033[31m${label}: DNS failed\033[0m" + ;; + tls_failed) + echo -e "\033[31m${label}: TLS failed\033[0m" + ;; + connect_failed) + echo -e "\033[31m${label}: connection failed\033[0m" + ;; + error_*) + echo -e "\033[31m${label}: failed (${result#error_})\033[0m" + ;; + *) + echo -e "\033[33m${label}: ${result}\033[0m" + ;; + esac +} + +function probeHostDnsResolution() { + local hostname="${1}" + local result + + if command -v getent >/dev/null 2>&1; then + result="$(getent ahostsv4 "${hostname}" 2>/dev/null | awk 'NR==1 {print $1}')" + if [[ -n "${result}" ]]; then + echo "resolved_${result}" + return 0 + fi + + result="$(getent hosts "${hostname}" 2>/dev/null | awk 'NR==1 {print $1}')" + if [[ -n "${result}" ]]; then + echo "resolved_${result}" + return 0 + fi + + echo "failed" + return 0 + fi + + if command -v host >/dev/null 2>&1; then + result="$(host "${hostname}" 2>/dev/null | awk '/has address/ {print $NF; exit}')" + if [[ -n "${result}" ]]; then + echo "resolved_${result}" + return 0 + fi + + echo "failed" + return 0 + fi + + echo "unavailable" +} + +function formatDnsProbeResult() { + local label="${1}" + local result="${2}" + + case "${result}" in + resolved_*) + echo -e "\033[33m${label}: resolved (${result#resolved_})\033[0m" + ;; + failed) + echo -e "\033[31m${label}: failed\033[0m" + ;; + unavailable) + echo -e "\033[33m${label}: unavailable\033[0m" + ;; + *) + echo -e "\033[33m${label}: ${result}\033[0m" + ;; + esac +} + +function formatCertificateProbeResult() { + local label="${1}" + local result="${2}" + local actual_code + + case "${result}" in + reachable_*) + actual_code="${result#reachable_}" + echo -e "\033[33m${label}: trusted (${actual_code})\033[0m" + ;; + tls_failed) + echo -e "\033[31m${label}: warning (TLS validation failed)\033[0m" + ;; + dns_failed) + echo -e "\033[31m${label}: unavailable (DNS failed)\033[0m" + ;; + connect_failed) + echo -e "\033[31m${label}: unavailable (connection failed)\033[0m" + ;; + unavailable) + echo -e "\033[33m${label}: unavailable\033[0m" + ;; + error_*) + echo -e "\033[31m${label}: unavailable (${result#error_})\033[0m" + ;; + *) + echo -e "\033[33m${label}: ${result}\033[0m" + ;; + esac +} + +if hasWindowsBridge; then + echo -e "\033[32mWindows Warden root certificate store state:\033[0m" + windows_store_state="$(getWindowsRootCaStoreState "${WARDEN_HOME_DIR}/ssl/rootca/certs/ca.cert.pem")" + case "${windows_store_state}" in + *"LocalMachine=present"* ) echo -e "\033[33mWindows LocalMachine Root: present\033[0m" ;; + *"LocalMachine=missing"* ) echo -e "\033[31mWindows LocalMachine Root: missing\033[0m" ;; + *"LocalMachine=unreadable"* ) echo -e "\033[31mWindows LocalMachine Root: unreadable\033[0m" ;; + esac + case "${windows_store_state}" in + *"CurrentUser=present"* ) echo -e "\033[33mWindows CurrentUser Root: present\033[0m" ;; + *"CurrentUser=missing"* ) echo -e "\033[31mWindows CurrentUser Root: missing\033[0m" ;; + *"CurrentUser=unreadable"* ) echo -e "\033[31mWindows CurrentUser Root: unreadable\033[0m" ;; + esac + + windows_doh_state="$(getWindowsDohTemplateState "${WARDEN_SERVICE_DOMAIN}")" + case "$(getWindowsStatusValue "${windows_doh_state}" "State")" in + present ) echo -e "\033[33mWindows DoH for 127.0.0.1: present ($(getWindowsStatusValue "${windows_doh_state}" "Template"))\033[0m" ;; + different ) echo -e "\033[33mWindows DoH for 127.0.0.1: differs ($(getWindowsStatusValue "${windows_doh_state}" "Template"))\033[0m" ;; + missing ) echo -e "\033[33mWindows DoH for 127.0.0.1: missing\033[0m" ;; + esac + + windows_hosts_state="$(getWindowsManagedHostsState "${WARDEN_SERVICE_DOMAIN}")" + case "$(getWindowsStatusValue "${windows_hosts_state}" "State")" in + present ) echo -e "\033[33mWindows Warden hosts block: present\033[0m" ;; + different ) echo -e "\033[33mWindows Warden hosts block: differs\033[0m" ;; + missing ) echo -e "\033[33mWindows Warden hosts block: missing\033[0m" ;; + esac + windows_hosts_entries="$(getWindowsStatusValue "${windows_hosts_state}" "Entries")" + if [[ -n "${windows_hosts_entries}" ]]; then + echo -e "\033[33mWindows Warden hosts entries: ${windows_hosts_entries//|/, }\033[0m" + fi + echo +fi + +echo -e "\033[32mDNS diagnostics:\033[0m" +random_wildcard_host="warden-doctor-$(date +%s)-${RANDOM}.${WARDEN_SERVICE_DOMAIN}" + +host_traefik_probe="$(probeHttpsUrl "curl" "https://traefik.${WARDEN_SERVICE_DOMAIN}/" "/dev/null")" +formatHttpsProbeResult "Host traefik HTTPS" "${host_traefik_probe}" + +host_webmail_probe="$(probeHttpsUrl "curl" "https://webmail.${WARDEN_SERVICE_DOMAIN}/" "/dev/null")" +formatHttpsProbeResult "Host webmail HTTPS" "${host_webmail_probe}" + +if [[ "${WARDEN_DNS_OVER_HTTPS_ENABLE}" == "1" ]]; then + host_doh_probe="$(probeHttpsUrl "curl" "https://doh.${WARDEN_SERVICE_DOMAIN}/dns-query" "/dev/null")" + formatHttpsProbeResult "Host DoH endpoint" "${host_doh_probe}" "400" +else + formatHttpsProbeResult "Host DoH endpoint" "disabled" +fi + +host_wildcard_probe="$(probeHttpsUrl "curl" "https://${random_wildcard_host}/" "/dev/null")" +formatHttpsProbeResult "Host wildcard HTTPS" "${host_wildcard_probe}" "404" + +host_dns_probe="$(probeHostDnsResolution "${random_wildcard_host}")" +formatDnsProbeResult "Host DNS-only lookup" "${host_dns_probe}" + +host_trafik_tls_probe="${host_traefik_probe}" + +if hasWindowsBridge; then + if [[ "${WARDEN_DNS_OVER_HTTPS_ENABLE}" == "1" ]]; then + windows_doh_probe="$(probeHttpsUrl "curl.exe" "https://doh.${WARDEN_SERVICE_DOMAIN}/dns-query" "NUL")" + formatHttpsProbeResult "Windows DoH endpoint" "${windows_doh_probe}" "400" + else + formatHttpsProbeResult "Windows DoH endpoint" "disabled" + fi + + windows_wildcard_probe="$(probeHttpsUrl "curl.exe" "https://${random_wildcard_host}/" "NUL")" + formatHttpsProbeResult "Windows wildcard HTTPS" "${windows_wildcard_probe}" "404" + + windows_dns_probe="$(probeWindowsDnsResolution "${random_wildcard_host}")" + case "$(getWindowsStatusValue "${windows_dns_probe}" "State")" in + resolved) + echo -e "\033[33mWindows DNS-only lookup: resolved ($(getWindowsStatusValue "${windows_dns_probe}" "Result"))\033[0m" + ;; + failed) + windows_dns_message="$(getWindowsStatusValue "${windows_dns_probe}" "Message")" + if [[ -n "${windows_dns_message}" ]]; then + echo -e "\033[31mWindows DNS-only lookup: failed (${windows_dns_message})\033[0m" + else + echo -e "\033[31mWindows DNS-only lookup: failed\033[0m" + fi + ;; + *) + echo -e "\033[33mWindows DNS-only lookup: unavailable\033[0m" + ;; + esac + windows_traefik_tls_probe="$(probeHttpsUrl "curl.exe" "https://traefik.${WARDEN_SERVICE_DOMAIN}/" "NUL")" +else + formatHttpsProbeResult "Windows DoH endpoint" "unavailable" + formatHttpsProbeResult "Windows wildcard HTTPS" "unavailable" + echo -e "\033[33mWindows DNS-only lookup: unavailable\033[0m" + windows_traefik_tls_probe="unavailable" +fi +echo + +echo -e "\033[32mTLS certificate diagnostics:\033[0m" +formatCertificateProbeResult "Host traefik certificate" "${host_trafik_tls_probe}" +formatCertificateProbeResult "Windows traefik certificate" "${windows_traefik_tls_probe}" +echo + echo -e "\033[32mWarden service override via Docker compose file:\033[0m" if [[ -f ${WARDEN_HOME_DIR}/docker-compose.yml ]]; then echo -e "\033[33mWarden services have additional service configuration added or overridden via ${WARDEN_HOME_DIR}/docker-compose.yml file.\033[0m" diff --git a/commands/install.cmd b/commands/install.cmd index e7dcae76..103a4fd0 100644 --- a/commands/install.cmd +++ b/commands/install.cmd @@ -8,6 +8,7 @@ if [[ ! -d "${WARDEN_SSL_DIR}/rootca" ]]; then touch "${WARDEN_SSL_DIR}/rootca/index.txt" echo 1000 > "${WARDEN_SSL_DIR}/rootca/serial" + echo 1000 > "${WARDEN_SSL_DIR}/rootca/crlnumber" fi # create CA root certificate if none present @@ -25,6 +26,21 @@ if [[ ! -f "${WARDEN_SSL_DIR}/rootca/certs/ca.cert.pem" ]]; then -subj "/C=US/O=Warden.dev/CN=Warden Proxy Local CA ($(hostname -s))" fi +if [[ ! -f "${WARDEN_SSL_DIR}/rootca/crlnumber" ]]; then + echo 1000 > "${WARDEN_SSL_DIR}/rootca/crlnumber" +fi + +if [[ ! -f "${WARDEN_SSL_DIR}/rootca/crl/ca.crl.pem" ]]; then + echo "==> Generating certificate revocation list for local root certificate" + openssl ca -gencrl -config "${WARDEN_DIR}/config/openssl/rootca.conf" \ + -out "${WARDEN_SSL_DIR}/rootca/crl/ca.crl.pem" +fi + +if [[ -f "${WARDEN_HOME_DIR}/.env" ]]; then + eval "$(grep "^WARDEN_SERVICE_DOMAIN" "${WARDEN_HOME_DIR}/.env")" +fi +WARDEN_SERVICE_DOMAIN="${WARDEN_SERVICE_DOMAIN:-warden.test}" + ## trust root ca differently on Fedora, Ubuntu and macOS if [[ "$OSTYPE" =~ ^linux ]] \ && [[ -d /etc/pki/ca-trust/source/anchors ]] \ @@ -51,15 +67,10 @@ then -k /Library/Keychains/System.keychain "${WARDEN_SSL_DIR}/rootca/certs/ca.cert.pem" fi -if hasWindowsCertificateBridge; then - echo "==> Trusting root certificate in Windows CurrentUser store" - if ! windows_trust_status="$(trustRootCaInWindows "${WARDEN_SSL_DIR}/rootca/certs/ca.cert.pem")"; then - warning "Unable to trust the Warden root certificate in Windows. Windows browsers may continue to warn until it is imported manually." - elif [[ "${windows_trust_status}" == "imported" ]]; then - echo "==> Root certificate imported into Windows CurrentUser Root store" - elif [[ "${windows_trust_status}" == "replaced" ]]; then - echo "==> Root certificate replaced in Windows CurrentUser Root store" - fi +if hasWindowsBridge; then + installWindowsRootCa "${WARDEN_SSL_DIR}/rootca/certs/ca.cert.pem" + installWindowsDohTemplate "${WARDEN_SERVICE_DOMAIN}" + installWindowsGlobalHosts "${WARDEN_SERVICE_DOMAIN}" fi ## configure resolver for .test domains on Mac OS only as Linux lacks support @@ -104,6 +115,8 @@ if [[ ! -f "${WARDEN_HOME_DIR}/.env" ]]; then WARDEN_PORTAINER_ENABLE=0 # Set to "0" to disable DNSMasq WARDEN_DNSMASQ_ENABLE=1 + # Set to "1" to enable experimental DNS-over-HTTPS at https://doh.\${WARDEN_SERVICE_DOMAIN:-warden.test}/dns-query + WARDEN_DNS_OVER_HTTPS_ENABLE=0 # Set to "0" to disable phpMyAdmin WARDEN_PHPMYADMIN_ENABLE=1 # Set to "0" to disabled Mutagen. Keep commented out to use System default (Darwin defaults to 1) diff --git a/commands/sign-certificate.cmd b/commands/sign-certificate.cmd index 7b63e69f..7576ecf2 100644 --- a/commands/sign-certificate.cmd +++ b/commands/sign-certificate.cmd @@ -12,6 +12,15 @@ if (( ${#WARDEN_PARAMS[@]} == 0 )); then exit -1 fi +if [[ ! -f "${WARDEN_SSL_DIR}/rootca/crlnumber" ]]; then + echo 1000 > "${WARDEN_SSL_DIR}/rootca/crlnumber" +fi + +if [[ ! -f "${WARDEN_SSL_DIR}/rootca/crl/ca.crl.pem" ]]; then + openssl ca -gencrl -config "${WARDEN_DIR}/config/openssl/rootca.conf" \ + -out "${WARDEN_SSL_DIR}/rootca/crl/ca.crl.pem" +fi + CERTIFICATE_SAN_LIST= for (( i = 0; i < ${#WARDEN_PARAMS[@]} * 2; i+=2 )); do [[ ${CERTIFICATE_SAN_LIST} ]] && CERTIFICATE_SAN_LIST+="," @@ -31,23 +40,15 @@ openssl genrsa -out "${WARDEN_SSL_DIR}/certs/${CERTIFICATE_NAME}.key.pem" 2048 echo "==> Generating signing req ${CERTIFICATE_NAME}.crt.pem" openssl req -new -sha256 -config <(cat \ "${WARDEN_DIR}/config/openssl/certificate.conf" \ - <(printf "extendedKeyUsage = serverAuth,clientAuth \n \ - subjectAltName = %s" "${CERTIFICATE_SAN_LIST}") \ + <(printf "subjectAltName = %s" "${CERTIFICATE_SAN_LIST}") \ ) \ -key "${WARDEN_SSL_DIR}/certs/${CERTIFICATE_NAME}.key.pem" \ -out "${WARDEN_SSL_DIR}/certs/${CERTIFICATE_NAME}.csr.pem" \ -subj "/C=US/O=Warden.dev/CN=${CERTIFICATE_NAME}" echo "==> Generating certificate ${CERTIFICATE_NAME}.crt.pem" -openssl x509 -req -days 365 -sha256 -extensions v3_req \ - -extfile <(cat \ - "${WARDEN_DIR}/config/openssl/certificate.conf" \ - <(printf "extendedKeyUsage = serverAuth,clientAuth \n \ - subjectAltName = %s" "${CERTIFICATE_SAN_LIST}") \ - ) \ - -CA "${WARDEN_SSL_DIR}/rootca/certs/ca.cert.pem" \ - -CAkey "${WARDEN_SSL_DIR}/rootca/private/ca.key.pem" \ - -CAserial "${WARDEN_SSL_DIR}/rootca/serial" \ +openssl ca -batch -config "${WARDEN_DIR}/config/openssl/rootca.conf" \ + -extensions server_cert -days 365 \ -in "${WARDEN_SSL_DIR}/certs/${CERTIFICATE_NAME}.csr.pem" \ -out "${WARDEN_SSL_DIR}/certs/${CERTIFICATE_NAME}.crt.pem" diff --git a/commands/svc.cmd b/commands/svc.cmd index 29af3e82..047c83f4 100644 --- a/commands/svc.cmd +++ b/commands/svc.cmd @@ -21,6 +21,8 @@ DOCKER_COMPOSE_ARGS+=("${WARDEN_DIR}/docker/docker-compose.yml") if [[ -f "${WARDEN_HOME_DIR}/.env" ]]; then # Check DNSMasq eval "$(grep "^WARDEN_DNSMASQ_ENABLE" "${WARDEN_HOME_DIR}/.env")" + # Check DNS over HTTPS + eval "$(grep "^WARDEN_DNS_OVER_HTTPS_ENABLE" "${WARDEN_HOME_DIR}/.env")" # Check Portainer eval "$(grep "^WARDEN_PORTAINER_ENABLE" "${WARDEN_HOME_DIR}/.env")" @@ -37,11 +39,24 @@ DOCKER_COMPOSE_ARGS+=("${WARDEN_DIR}/docker/docker-compose.mailpit.yml") ## add dnsmasq docker-compose WARDEN_DNSMASQ_ENABLE="${WARDEN_DNSMASQ_ENABLE:-1}" +WARDEN_DNS_OVER_HTTPS_ENABLE="${WARDEN_DNS_OVER_HTTPS_ENABLE:-0}" +if [[ "$WARDEN_DNS_OVER_HTTPS_ENABLE" == "1" ]]; then + if [[ "$WARDEN_DNSMASQ_ENABLE" != "1" ]]; then + warning "WARDEN_DNS_OVER_HTTPS_ENABLE requires dnsmasq; enabling Warden dnsmasq for this global services run" + WARDEN_DNSMASQ_ENABLE="1" + fi +fi + if [[ "$WARDEN_DNSMASQ_ENABLE" == "1" ]]; then DOCKER_COMPOSE_ARGS+=("-f") DOCKER_COMPOSE_ARGS+=("${WARDEN_DIR}/docker/docker-compose.dnsmasq.yml") fi +if [[ "$WARDEN_DNS_OVER_HTTPS_ENABLE" == "1" ]]; then + DOCKER_COMPOSE_ARGS+=("-f") + DOCKER_COMPOSE_ARGS+=("${WARDEN_DIR}/docker/docker-compose.dns-over-https.yml") +fi + WARDEN_PORTAINER_ENABLE="${WARDEN_PORTAINER_ENABLE:-0}" if [[ "${WARDEN_PORTAINER_ENABLE}" == 1 ]]; then DOCKER_COMPOSE_ARGS+=("-f") @@ -107,6 +122,32 @@ if [[ "${WARDEN_PARAMS[0]}" == "up" ]]; then EOF done + if [[ "$WARDEN_DNS_OVER_HTTPS_ENABLE" == "1" ]]; then + mkdir -p "${WARDEN_HOME_DIR}/etc/pki-public" + cp "${WARDEN_SSL_DIR}/rootca/certs/ca.cert.pem" "${WARDEN_HOME_DIR}/etc/pki-public/ca.cert.pem" + cp "${WARDEN_SSL_DIR}/rootca/crl/ca.crl.pem" "${WARDEN_HOME_DIR}/etc/pki-public/ca.crl.pem" + fi + + # Keep normal HTTP->HTTPS redirects in dynamic config so specific paths + # such as `/.warden/pki/` can remain opt-in plain HTTP when required + # for TLS validation metadata retrieval. + cat >> "${WARDEN_HOME_DIR}/etc/traefik/dynamic.yml" <<-'EOT' + http: + routers: + http-catchall-redirect: + entryPoints: + - http + rule: PathPrefix(`/`) + middlewares: + - http-redirect-to-https + service: noop@internal + middlewares: + http-redirect-to-https: + redirectScheme: + scheme: https + permanent: true + EOT + ## always execute svc up using --detach mode if ! (containsElement "-d" "$@" || containsElement "--detach" "$@"); then WARDEN_PARAMS=("${WARDEN_PARAMS[@]:1}") diff --git a/config/openssl/rootca.conf b/config/openssl/rootca.conf index e55c55ac..99ab3ee4 100644 --- a/config/openssl/rootca.conf +++ b/config/openssl/rootca.conf @@ -6,7 +6,7 @@ default_ca = CA_default [ CA_default ] # Directory and file locations. -dir = ~/.warden/ssl/rootca +dir = $ENV::HOME/.warden/ssl/rootca certs = $dir/certs crl_dir = $dir/crl new_certs_dir = $dir/newcerts @@ -109,6 +109,10 @@ subjectKeyIdentifier = hash authorityKeyIdentifier = keyid,issuer:always keyUsage = critical, digitalSignature, keyEncipherment extendedKeyUsage = serverAuth +# Publish local CRL/AIA metadata for DoH clients that require working +# issuer/revocation retrieval during HTTPS validation. +crlDistributionPoints = URI:http://127.0.0.1/.warden/pki/ca.crl.pem +authorityInfoAccess = caIssuers;URI:http://127.0.0.1/.warden/pki/ca.cert.pem [ crl_ext ] # Extension for CRLs (`man x509v3_config`). diff --git a/config/traefik/traefik.yml b/config/traefik/traefik.yml index 4f8f8593..1763d07e 100644 --- a/config/traefik/traefik.yml +++ b/config/traefik/traefik.yml @@ -11,11 +11,6 @@ providers: entryPoints: http: address: ":80" - http: - redirections: - entryPoint: - to: https - scheme: https https: address: ":443" log: diff --git a/docker/dns-over-https/docker-entrypoint.sh b/docker/dns-over-https/docker-entrypoint.sh new file mode 100755 index 00000000..71fa4f4f --- /dev/null +++ b/docker/dns-over-https/docker-entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/sh +set -eu + +upstream_host="${WARDEN_DNS_OVER_HTTPS_UPSTREAM_HOST:-dnsmasq}" +upstream_ip="$(getent hosts "${upstream_host}" | awk 'NR==1 { print $1 }')" + +if [ -z "${upstream_ip}" ]; then + echo "Failed to resolve DNS-over-HTTPS upstream host: ${upstream_host}" >&2 + exit 1 +fi + +printf '%s\n' "${WARDEN_DNS_OVER_HTTPS_CONFIG}" \ + | sed "s/__WARDEN_DNS_OVER_HTTPS_UPSTREAM__/${upstream_ip}/g" \ + > /tmp/warden-dnsdist.conf + +exec dnsdist --supervised --disable-syslog --config /tmp/warden-dnsdist.conf diff --git a/docker/docker-compose.dns-over-https.yml b/docker/docker-compose.dns-over-https.yml new file mode 100644 index 00000000..18b6bcdb --- /dev/null +++ b/docker/docker-compose.dns-over-https.yml @@ -0,0 +1,49 @@ +services: + dns-over-https-pki: + container_name: dns-over-https-pki + image: busybox:1.36.1 + entrypoint: + - /bin/sh + - -c + command: + - exec httpd -f -v -p 80 -h /www + volumes: + - ${WARDEN_HOME_DIR}/etc/pki-public:/www:ro + labels: + - traefik.enable=true + # pki must be explicetly opt-in for plain http instead of https for proper revocation endpoint behavior + - traefik.http.routers.dns-over-https-pki.rule=PathPrefix(`/.warden/pki/`) + - traefik.http.routers.dns-over-https-pki.entrypoints=http + - traefik.http.routers.dns-over-https-pki.priority=1000 + - traefik.http.routers.dns-over-https-pki.service=dns-over-https-pki + - traefik.http.routers.dns-over-https-pki.middlewares=dns-over-https-pki-strip-prefix + - traefik.http.middlewares.dns-over-https-pki-strip-prefix.stripprefix.prefixes=/.warden/pki + - traefik.http.services.dns-over-https-pki.loadbalancer.server.port=80 + restart: ${WARDEN_RESTART_POLICY:-always} + + dns-over-https: + container_name: dns-over-https + image: powerdns/dnsdist-19:1.9.11 + depends_on: + - dnsmasq + environment: + WARDEN_DNS_OVER_HTTPS_UPSTREAM_HOST: dnsmasq + WARDEN_DNS_OVER_HTTPS_CONFIG: |- + -- Accept any source that can already reach this local-dev service. Access control is handled by Docker networking and Traefik exposure. + setACL({"0.0.0.0/0", "::/0"}) + + newServer({address="__WARDEN_DNS_OVER_HTTPS_UPSTREAM__:53"}) + addDOHLocal("0.0.0.0:8053", nil, nil, {"/dns-query"}) + volumes: + - ${WARDEN_SERVICE_DIR}/docker/dns-over-https/docker-entrypoint.sh:/usr/local/bin/warden-dns-over-https-entrypoint.sh:ro + entrypoint: + - /usr/local/bin/warden-dns-over-https-entrypoint.sh + labels: + - traefik.enable=true + - traefik.http.routers.dns-over-https.tls=true + - traefik.http.routers.dns-over-https.priority=100 + - traefik.http.routers.dns-over-https.rule=Host(`doh.${WARDEN_SERVICE_DOMAIN:-warden.test}`) && Path(`/dns-query`) + - traefik.http.routers.dns-over-https.service=dns-over-https + - traefik.http.services.dns-over-https.loadbalancer.server.port=8053 + - traefik.http.services.dns-over-https.loadbalancer.server.scheme=h2c + restart: ${WARDEN_RESTART_POLICY:-always} diff --git a/utils/install.sh b/utils/install.sh index d84847f1..fe06e3e2 100644 --- a/utils/install.sh +++ b/utils/install.sh @@ -3,71 +3,9 @@ source "${WARDEN_DIR}/utils/core.sh" -function isWsl () { - [[ -n "${WSL_DISTRO_NAME:-}" ]] && return 0 - [[ -r /proc/sys/kernel/osrelease ]] && grep -qiE '(microsoft|wsl)' /proc/sys/kernel/osrelease && return 0 - [[ -r /proc/version ]] && grep -qiE '(microsoft|wsl)' /proc/version && return 0 - return 1 -} - -function hasWindowsCertificateBridge () { - isWsl && command -v powershell.exe >/dev/null 2>&1 -} - -function trustRootCaInWindows () { - local cert_path="${1}" - local windows_cert_path powershell_script trust_status - - [[ -f "${cert_path}" ]] || return 1 - - windows_cert_path="$(wslpath -w "${cert_path}")" || return 1 - windows_cert_path="${windows_cert_path//\'/\'\'}" - - read -r -d '' powershell_script <<-EOT || true - & { - \$certPath = '${windows_cert_path}' - if (-not (Test-Path \$certPath)) { - throw "Certificate path not found: \$certPath" - } - - \$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(\$certPath) - \$store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root', 'CurrentUser') - \$store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) - - try { - \$existing = \$store.Certificates | Where-Object { \$_.Thumbprint -eq \$cert.Thumbprint } - if (\$existing) { - Write-Output 'present' - return - } - - \$staleWardenRoots = @( - \$store.Certificates | Where-Object { - \$_.Thumbprint -ne \$cert.Thumbprint -and - \$_.Subject -like '*O=Warden.dev*' -and - \$_.Subject -like '*CN=Warden Proxy Local CA*' - } - ) - \$store.Add(\$cert) - foreach (\$staleCert in \$staleWardenRoots) { - \$store.Remove(\$staleCert) - } - if (\$staleWardenRoots.Count -gt 0) { - Write-Output 'replaced' - } else { - Write-Output 'imported' - } - } finally { - \$store.Close() - } - } - EOT - - trust_status="$(powershell.exe -NoProfile -NonInteractive -Command "${powershell_script}" | tr -d '\r')" || return 1 - [[ "${trust_status}" =~ ^(present|imported|replaced)$ ]] || return 1 - - echo "${trust_status}" -} +# Load WSL/Windows bridge helpers. This only defines functions; +# install.cmd and doctor.cmd still decide when to call them. +source "${WARDEN_DIR}/utils/windows/install.sh" function installSshConfig () { if ! grep '## WARDEN START ##' /etc/ssh/ssh_config >/dev/null; then diff --git a/utils/windows/get-certificate-thumbprint.ps1 b/utils/windows/get-certificate-thumbprint.ps1 new file mode 100644 index 00000000..7cbb09a7 --- /dev/null +++ b/utils/windows/get-certificate-thumbprint.ps1 @@ -0,0 +1,9 @@ +param( + [Parameter(Mandatory = $true)] + [string]$CertificatePath +) + +$ErrorActionPreference = 'Stop' + +$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($CertificatePath) +Write-Output $cert.Thumbprint diff --git a/utils/windows/get-doh-template-state.ps1 b/utils/windows/get-doh-template-state.ps1 new file mode 100644 index 00000000..72014701 --- /dev/null +++ b/utils/windows/get-doh-template-state.ps1 @@ -0,0 +1,39 @@ +param( + [Parameter(Mandatory = $true)] + [string]$ServerAddress, + + [Parameter(Mandatory = $true)] + [string]$DohTemplate, + + [Parameter(Mandatory = $true)] + [int]$AllowFallbackToUdp, + + [Parameter(Mandatory = $true)] + [int]$AutoUpgrade +) + +$ErrorActionPreference = 'Stop' +$allowFallbackToUdpExpected = [bool]$AllowFallbackToUdp +$autoUpgradeExpected = [bool]$AutoUpgrade + +$entry = Get-DnsClientDohServerAddress -ServerAddress $ServerAddress -ErrorAction SilentlyContinue | Select-Object -First 1 + +if (-not $entry) { + Write-Output 'State=missing' + exit 0 +} + +$state = if ( + $entry.DohTemplate -eq $DohTemplate -and + $entry.AllowFallbackToUdp -eq $allowFallbackToUdpExpected -and + $entry.AutoUpgrade -eq $autoUpgradeExpected +) { + 'present' +} else { + 'different' +} + +Write-Output "State=$state" +Write-Output "Template=$($entry.DohTemplate)" +Write-Output "AllowFallbackToUdp=$($entry.AllowFallbackToUdp)" +Write-Output "AutoUpgrade=$($entry.AutoUpgrade)" diff --git a/utils/windows/get-managed-hosts-state.ps1 b/utils/windows/get-managed-hosts-state.ps1 new file mode 100644 index 00000000..353232aa --- /dev/null +++ b/utils/windows/get-managed-hosts-state.ps1 @@ -0,0 +1,61 @@ +param( + [Parameter(Mandatory = $true)] + [string]$BlockStart, + + [Parameter(Mandatory = $true)] + [string]$BlockEnd, + + [Parameter(Mandatory = $true)] + [string]$EntriesText +) + +$ErrorActionPreference = 'Stop' + +$expectedEntries = if ([string]::IsNullOrEmpty($EntriesText)) { + @() +} else { + $EntriesText.Split('|') | Where-Object { $_ -ne '' } +} + +$hostsPath = Join-Path $env:SystemRoot 'System32\drivers\etc\hosts' +$lines = if (Test-Path $hostsPath) { + [System.IO.File]::ReadAllLines($hostsPath) +} else { + @() +} + +$startIndex = -1 +$endIndex = -1 +for ($i = 0; $i -lt $lines.Length; $i++) { + if ($lines[$i] -eq $BlockStart) { + $startIndex = $i + continue + } + + if ($startIndex -ge 0 -and $lines[$i] -eq $BlockEnd) { + $endIndex = $i + break + } +} + +if ($startIndex -lt 0 -or $endIndex -lt 0 -or $endIndex -le $startIndex) { + Write-Output 'State=missing' + exit 0 +} + +$currentEntries = @() +if ($endIndex -gt ($startIndex + 1)) { + $currentEntries = $lines[($startIndex + 1)..($endIndex - 1)] | Where-Object { $_ -ne '' } +} + +$state = if ( + $currentEntries.Count -eq $expectedEntries.Count -and + (@(Compare-Object -ReferenceObject $expectedEntries -DifferenceObject $currentEntries -SyncWindow 0).Count -eq 0) +) { + 'present' +} else { + 'different' +} + +Write-Output "State=$state" +Write-Output "Entries=$([string]::Join('|', $currentEntries))" diff --git a/utils/windows/get-root-store-state.ps1 b/utils/windows/get-root-store-state.ps1 new file mode 100644 index 00000000..33a7827f --- /dev/null +++ b/utils/windows/get-root-store-state.ps1 @@ -0,0 +1,36 @@ +param( + [Parameter(Mandatory = $true)] + [string]$CertificatePath +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path $CertificatePath)) { + throw "Certificate path not found: $CertificatePath" +} + +$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($CertificatePath) +$states = @() + +foreach ($storeLocation in @('LocalMachine', 'CurrentUser')) { + $store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root', $storeLocation) + try { + try { + $store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly) + } catch [System.Security.Cryptography.CryptographicException] { + $states += "${storeLocation}=unreadable" + continue + } + + $existing = $store.Certificates | Where-Object { $_.Thumbprint -eq $cert.Thumbprint } + if ($existing) { + $states += "${storeLocation}=present" + } else { + $states += "${storeLocation}=missing" + } + } finally { + $store.Close() + } +} + +Write-Output ($states -join ';') diff --git a/utils/windows/import-root-localmachine-elevated.ps1 b/utils/windows/import-root-localmachine-elevated.ps1 new file mode 100644 index 00000000..a0ee32eb --- /dev/null +++ b/utils/windows/import-root-localmachine-elevated.ps1 @@ -0,0 +1,96 @@ +# +# Handles UAC elevation for LocalMachine\Root import, passes work to the +# elevated import script, and translates the result back to WSL. +# +param( + [Parameter(Mandatory = $true)] + [string]$CertificatePath, + + [Parameter(Mandatory = $true)] + [string]$Thumbprint, + + [Parameter(Mandatory = $true)] + [string]$ImportScriptPath +) + +$ErrorActionPreference = 'Stop' + +function Test-ElevationCancelledError { + param($ErrorRecord) + + return ( + $ErrorRecord.Exception.HResult -eq -2147023673 -or + $ErrorRecord.Exception.Message -match 'cancelled by the user' + ) +} + +$scriptPath = [System.IO.Path]::Combine($env:TEMP, 'Warden-Import-Root-Certificate-' + [guid]::NewGuid().ToString() + '.ps1') +$statusPath = [System.IO.Path]::Combine($env:TEMP, 'warden-rootca-import-' + [guid]::NewGuid().ToString() + '.txt') +$tempCertPath = [System.IO.Path]::Combine($env:TEMP, 'warden-rootca-' + [guid]::NewGuid().ToString() + '.pem') + +Copy-Item $CertificatePath $tempCertPath -Force +Copy-Item $ImportScriptPath $scriptPath -Force + +try { + try { + $process = Start-Process powershell.exe -Verb RunAs -Wait -PassThru -ArgumentList @( + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + $scriptPath, + '-CertificatePath', + $tempCertPath, + '-Thumbprint', + $Thumbprint, + '-StatusPath', + $statusPath + ) + } catch { + if (Test-ElevationCancelledError $_) { + Write-Output 'elevation_cancelled' + return + } + + Write-Output 'elevation_failed' + return + } + + if (-not (Test-Path $statusPath)) { + Write-Output 'elevation_failed' + return + } + + $status = Get-Content -Path $statusPath -Raw + if ($status -eq 'policy_blocked') { + Write-Output 'policy_blocked' + return + } + + if ($process.ExitCode -ne 0) { + Write-Output 'elevation_failed' + return + } + + if ($status -ne 'imported') { + Write-Output 'elevation_failed' + return + } + + $verifyStore = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root', 'LocalMachine') + $verifyStore.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly) + try { + $verified = $verifyStore.Certificates | Where-Object { $_.Thumbprint -eq $Thumbprint } + if ($verified) { + Write-Output 'imported' + } else { + Write-Output 'elevation_failed' + } + } finally { + $verifyStore.Close() + } +} finally { + Remove-Item -Path $scriptPath -ErrorAction SilentlyContinue + Remove-Item -Path $statusPath -ErrorAction SilentlyContinue + Remove-Item -Path $tempCertPath -ErrorAction SilentlyContinue +} diff --git a/utils/windows/import-root-localmachine.ps1 b/utils/windows/import-root-localmachine.ps1 new file mode 100644 index 00000000..43742179 --- /dev/null +++ b/utils/windows/import-root-localmachine.ps1 @@ -0,0 +1,83 @@ +# +# Performs the actual LocalMachine\Root import and writes a status token for +# the caller. This script is intended to run inside the elevated process. +# +param( + [Parameter(Mandatory = $true)] + [string]$CertificatePath, + + [Parameter(Mandatory = $true)] + [string]$Thumbprint, + + [Parameter(Mandatory = $true)] + [string]$StatusPath +) + +$ErrorActionPreference = 'Stop' + +function Test-AccessDeniedError { + param($ErrorRecord) + + return ( + $ErrorRecord.Exception.HResult -eq -2147024891 -or + $ErrorRecord.Exception.Message -match 'Access is denied' + ) +} + +function Test-PolicyBlockedError { + param($ErrorRecord) + + return $ErrorRecord.Exception.Message -match 'group policy|policy|administrator has blocked|managed by your organization' +} + +$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($CertificatePath) +$store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root', 'LocalMachine') +try { + $store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) +} catch [System.Security.Cryptography.CryptographicException] { + if (Test-AccessDeniedError $_) { + Set-Content -Path $StatusPath -Value 'access_denied' -NoNewline + exit 1 + } + if (Test-PolicyBlockedError $_) { + Set-Content -Path $StatusPath -Value 'policy_blocked' -NoNewline + exit 1 + } + Set-Content -Path $StatusPath -Value 'store_error' -NoNewline + exit 1 +} + +try { + try { + $existing = $store.Certificates | Where-Object { $_.Thumbprint -eq $Thumbprint } + if (-not $existing) { + $staleWardenRoots = @( + $store.Certificates | Where-Object { + $_.Thumbprint -ne $Thumbprint -and + $_.Subject -like '*O=Warden.dev*' -and + $_.Subject -like '*CN=Warden Proxy Local CA*' + } + ) + + $store.Add($cert) + + foreach ($staleCert in $staleWardenRoots) { + $store.Remove($staleCert) + } + } + } catch [System.Security.Cryptography.CryptographicException] { + if (Test-PolicyBlockedError $_) { + Set-Content -Path $StatusPath -Value 'policy_blocked' -NoNewline + } else { + Set-Content -Path $StatusPath -Value 'store_error' -NoNewline + } + exit 1 + } catch { + Set-Content -Path $StatusPath -Value 'store_error' -NoNewline + exit 1 + } +} finally { + $store.Close() +} + +Set-Content -Path $StatusPath -Value 'imported' -NoNewline diff --git a/utils/windows/install-doh-template-elevated.ps1 b/utils/windows/install-doh-template-elevated.ps1 new file mode 100644 index 00000000..b40f75b9 --- /dev/null +++ b/utils/windows/install-doh-template-elevated.ps1 @@ -0,0 +1,89 @@ +param( + [Parameter(Mandatory = $true)] + [string]$ServerAddress, + + [Parameter(Mandatory = $true)] + [string]$DohTemplate, + + [Parameter(Mandatory = $true)] + [int]$AllowFallbackToUdp, + + [Parameter(Mandatory = $true)] + [int]$AutoUpgrade +) + +$ErrorActionPreference = 'Stop' + +function Test-ElevationCancelledError { + param($ErrorRecord) + + return ( + $ErrorRecord.Exception.HResult -eq -2147023673 -or + $ErrorRecord.Exception.Message -match 'cancelled by the user' + ) +} + +$statusPath = [System.IO.Path]::Combine($env:TEMP, 'warden-doh-template-' + [guid]::NewGuid().ToString() + '.txt') +$childPath = [System.IO.Path]::Combine($env:TEMP, 'warden-doh-template-child-' + [guid]::NewGuid().ToString() + '.ps1') +$allowFallbackToUdpLiteral = if ([bool]$AllowFallbackToUdp) { '$true' } else { '$false' } +$autoUpgradeLiteral = if ([bool]$AutoUpgrade) { '$true' } else { '$false' } + +@" +`$ErrorActionPreference = 'Stop' +try { + `$existing = Get-DnsClientDohServerAddress -ServerAddress '$ServerAddress' -ErrorAction SilentlyContinue | Select-Object -First 1 + `$status = if (`$existing) { 'updated' } else { 'installed' } + + if (`$existing) { + Remove-DnsClientDohServerAddress -ServerAddress '$ServerAddress' -ErrorAction SilentlyContinue + } + + Add-DnsClientDohServerAddress -ServerAddress '$ServerAddress' -DohTemplate '$DohTemplate' -AllowFallbackToUdp $allowFallbackToUdpLiteral -AutoUpgrade $autoUpgradeLiteral + Set-Content -Path '$statusPath' -Value `$status -NoNewline + exit 0 +} catch { + Set-Content -Path '$statusPath' -Value ('error:' + `$_.Exception.Message) -NoNewline + exit 1 +} +"@ | Set-Content -Path $childPath -NoNewline + +try { + try { + $process = Start-Process powershell.exe -Verb RunAs -Wait -PassThru -ArgumentList @( + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + $childPath + ) + } catch { + if (Test-ElevationCancelledError $_) { + Write-Output 'elevation_cancelled' + return + } + + Write-Output 'elevation_failed' + return + } + + if (-not (Test-Path $statusPath)) { + Write-Output 'elevation_failed' + return + } + + $status = Get-Content -Path $statusPath -Raw + if ($status -eq 'installed' -or $status -eq 'updated') { + Write-Output $status + return + } + + if ($process.ExitCode -ne 0) { + Write-Output 'elevation_failed' + return + } + + Write-Output 'elevation_failed' +} finally { + Remove-Item -Path $childPath -ErrorAction SilentlyContinue + Remove-Item -Path $statusPath -ErrorAction SilentlyContinue +} diff --git a/utils/windows/install-managed-hosts-elevated.ps1 b/utils/windows/install-managed-hosts-elevated.ps1 new file mode 100644 index 00000000..3aa1c67c --- /dev/null +++ b/utils/windows/install-managed-hosts-elevated.ps1 @@ -0,0 +1,143 @@ +param( + [Parameter(Mandatory = $true)] + [string]$BlockStart, + + [Parameter(Mandatory = $true)] + [string]$BlockEnd, + + [Parameter(Mandatory = $true)] + [string]$EntriesText +) + +$ErrorActionPreference = 'Stop' + +function Test-ElevationCancelledError { + param($ErrorRecord) + + return ( + $ErrorRecord.Exception.HResult -eq -2147023673 -or + $ErrorRecord.Exception.Message -match 'cancelled by the user' + ) +} + +$statusPath = [System.IO.Path]::Combine($env:TEMP, 'warden-hosts-status-' + [guid]::NewGuid().ToString() + '.txt') +$childPath = [System.IO.Path]::Combine($env:TEMP, 'warden-hosts-child-' + [guid]::NewGuid().ToString() + '.ps1') + +@" +`$ErrorActionPreference = 'Stop' +try { + `$hostsPath = Join-Path `$env:SystemRoot 'System32\drivers\etc\hosts' + `$lines = if (Test-Path `$hostsPath) { [System.IO.File]::ReadAllLines(`$hostsPath) } else { @() } + `$entries = '$EntriesText'.Split('|') | Where-Object { `$_ -ne '' } + + `$startIndex = -1 + `$endIndex = -1 + for (`$i = 0; `$i -lt `$lines.Length; `$i++) { + if (`$lines[`$i] -eq '$BlockStart') { + `$startIndex = `$i + continue + } + + if (`$startIndex -ge 0 -and `$lines[`$i] -eq '$BlockEnd') { + `$endIndex = `$i + break + } + } + + `$status = 'installed' + `$currentEntries = @() + `$baseLines = @() + + if (`$startIndex -ge 0 -and `$endIndex -gt `$startIndex) { + if (`$endIndex -gt (`$startIndex + 1)) { + `$currentEntries = `$lines[(`$startIndex + 1)..(`$endIndex - 1)] | Where-Object { `$_ -ne '' } + } + `$status = 'updated' + if ( + `$currentEntries.Count -eq `$entries.Count -and + (@(Compare-Object -ReferenceObject `$entries -DifferenceObject `$currentEntries -SyncWindow 0).Count -eq 0) + ) { + `$status = 'present' + } + + if (`$startIndex -gt 0) { + `$baseLines += `$lines[0..(`$startIndex - 1)] + } + if (`$endIndex -lt (`$lines.Length - 1)) { + `$baseLines += `$lines[(`$endIndex + 1)..(`$lines.Length - 1)] + } + } else { + `$baseLines = @(`$lines) + } + + if (`$status -eq 'present') { + Set-Content -Path '$statusPath' -Value `$status -NoNewline + exit 0 + } + + while (`$baseLines.Count -gt 0 -and [string]::IsNullOrWhiteSpace(`$baseLines[-1])) { + `$baseLines = if (`$baseLines.Count -gt 1) { `$baseLines[0..(`$baseLines.Count - 2)] } else { @() } + } + + `$newLines = New-Object System.Collections.Generic.List[string] + foreach (`$line in `$baseLines) { + [void]`$newLines.Add(`$line) + } + if (`$newLines.Count -gt 0) { + [void]`$newLines.Add('') + } + [void]`$newLines.Add('$BlockStart') + foreach (`$entry in `$entries) { + [void]`$newLines.Add(`$entry) + } + [void]`$newLines.Add('$BlockEnd') + + [System.IO.File]::WriteAllLines(`$hostsPath, `$newLines) + Set-Content -Path '$statusPath' -Value `$status -NoNewline + exit 0 +} catch { + Set-Content -Path '$statusPath' -Value ('error:' + `$_.Exception.Message) -NoNewline + exit 1 +} +"@ | Set-Content -Path $childPath -NoNewline + +try { + try { + $process = Start-Process powershell.exe -Verb RunAs -Wait -PassThru -ArgumentList @( + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + $childPath + ) + } catch { + if (Test-ElevationCancelledError $_) { + Write-Output 'elevation_cancelled' + return + } + + Write-Output 'elevation_failed' + return + } + + if (-not (Test-Path $statusPath)) { + Write-Output 'elevation_failed' + return + } + + $status = Get-Content -Path $statusPath -Raw + if ($status -eq 'installed' -or $status -eq 'updated' -or $status -eq 'present') { + Write-Output $status + return + } + + if ($process.ExitCode -ne 0) { + Write-Output 'elevation_failed' + return + } + + Write-Output 'elevation_failed' +} finally { + Remove-Item -Path $childPath -ErrorAction SilentlyContinue + Remove-Item -Path $statusPath -ErrorAction SilentlyContinue +} diff --git a/utils/windows/install.sh b/utils/windows/install.sh new file mode 100644 index 00000000..9bc09120 --- /dev/null +++ b/utils/windows/install.sh @@ -0,0 +1,376 @@ +#!/usr/bin/env bash +[[ ! ${WARDEN_DIR} ]] && >&2 echo -e "\033[31mThis script is not intended to be run directly!\033[0m" && exit 1 + +# WSL/Windows bridge helpers used by install and doctor flows. + +WINDOWS_MANAGED_HOSTS_BLOCK_START="# WARDEN WINDOWS HOSTS START" +WINDOWS_MANAGED_HOSTS_BLOCK_END="# WARDEN WINDOWS HOSTS END" + +function isWsl () { + command -v wslpath >/dev/null 2>&1 || return 1 + + [[ -n "${WSL_DISTRO_NAME:-}" ]] && wslpath -w . >/dev/null 2>&1 && return 0 + [[ -r /proc/sys/kernel/osrelease ]] && grep -qiE '(microsoft|wsl)' /proc/sys/kernel/osrelease && wslpath -w . >/dev/null 2>&1 && return 0 + [[ -r /proc/version ]] && grep -qiE '(microsoft|wsl)' /proc/version && wslpath -w . >/dev/null 2>&1 && return 0 + return 1 +} + +function hasWindowsBridge () { + isWsl && command -v powershell.exe >/dev/null 2>&1 +} + +function runWindowsPowerShellScript () { + local script_path="${1}" + shift + + powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${script_path}" "$@" | tr -d '\r' +} + +function toWindowsPath () { + wslpath -w "$(realpath "${1}")" +} + +function sendWindowsNotification () { + local title="${1}" + local message="${2}" + local level="${3:-Info}" + + hasWindowsBridge || return 0 + + powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden \ + -File "$(toWindowsPath "${WARDEN_DIR}/utils/windows/show-notification.ps1")" \ + -Title "${title}" \ + -Message "${message}" \ + -Level "${level}" >/dev/null 2>&1 || true +} + +function getWindowsStatusValue () { + local status_output="${1}" + local key="${2}" + + printf '%s\n' "${status_output}" | sed -n "s/^${key}=//p" | head -n 1 +} + +function getWindowsGlobalHostsEntries () { + local service_domain="${1}" + + printf '%s\n' \ + "127.0.0.1 traefik.${service_domain}" \ + "127.0.0.1 dnsmasq.${service_domain}" \ + "127.0.0.1 doh.${service_domain}" \ + "127.0.0.1 webmail.${service_domain}" +} + +function getWindowsCertificateThumbprint () { + local cert_path="${1}" + local windows_cert_path thumbprint + + [[ -f "${cert_path}" ]] || return 1 + + windows_cert_path="$(wslpath -w "${cert_path}")" || return 1 + thumbprint="$(runWindowsPowerShellScript "$(toWindowsPath "${WARDEN_DIR}/utils/windows/get-certificate-thumbprint.ps1")" -CertificatePath "${windows_cert_path}")" || return 1 + [[ -n "${thumbprint}" ]] || return 1 + + echo "${thumbprint}" +} + +function getWindowsRootCaStoreState () { + local cert_path="${1}" + local windows_cert_path store_state + + [[ -f "${cert_path}" ]] || return 1 + + windows_cert_path="$(wslpath -w "${cert_path}")" || return 1 + store_state="$(runWindowsPowerShellScript "$(toWindowsPath "${WARDEN_DIR}/utils/windows/get-root-store-state.ps1")" -CertificatePath "${windows_cert_path}")" || return 1 + [[ -n "${store_state}" ]] || return 1 + + echo "${store_state}" +} + +function getWindowsDohTemplateState () { + local service_domain="${1}" + local state_output + local script_path + + script_path="$(toWindowsPath "${WARDEN_DIR}/utils/windows/get-doh-template-state.ps1")" || return 1 + state_output="$(runWindowsPowerShellScript "${script_path}" \ + -ServerAddress "127.0.0.1" \ + -DohTemplate "https://doh.${service_domain}/dns-query" \ + -AllowFallbackToUdp 0 \ + -AutoUpgrade 1)" || return 1 + [[ -n "${state_output}" ]] || return 1 + + echo "${state_output}" +} + +function getWindowsManagedHostsState () { + local service_domain="${1}" + local script_path state_output + local host_entries=() + local host_entries_text + + while IFS= read -r entry; do + host_entries+=("${entry}") + done < <(getWindowsGlobalHostsEntries "${service_domain}") + host_entries_text="$(printf '%s|' "${host_entries[@]}")" + host_entries_text="${host_entries_text%|}" + + script_path="$(toWindowsPath "${WARDEN_DIR}/utils/windows/get-managed-hosts-state.ps1")" || return 1 + state_output="$(runWindowsPowerShellScript "${script_path}" \ + -BlockStart "${WINDOWS_MANAGED_HOSTS_BLOCK_START}" \ + -BlockEnd "${WINDOWS_MANAGED_HOSTS_BLOCK_END}" \ + -EntriesText "${host_entries_text}")" || return 1 + [[ -n "${state_output}" ]] || return 1 + + echo "${state_output}" +} + +function probeWindowsDnsResolution () { + local hostname="${1}" + local script_path state_output + + script_path="$(toWindowsPath "${WARDEN_DIR}/utils/windows/test-dns-resolution.ps1")" || return 1 + state_output="$(runWindowsPowerShellScript "${script_path}" -Hostname "${hostname}")" || return 1 + [[ -n "${state_output}" ]] || return 1 + + echo "${state_output}" +} + +function trustRootCaInWindowsStore () { + local cert_path="${1}" + local store_location="${2}" + local windows_cert_path trust_status + + [[ -f "${cert_path}" ]] || return 1 + [[ "${store_location}" =~ ^(CurrentUser|LocalMachine)$ ]] || return 1 + + windows_cert_path="$(wslpath -w "${cert_path}")" || return 1 + trust_status="$(runWindowsPowerShellScript "$(toWindowsPath "${WARDEN_DIR}/utils/windows/trust-root-store.ps1")" -CertificatePath "${windows_cert_path}" -StoreLocation "${store_location}")" || return 1 + [[ "${trust_status}" =~ ^(present|imported|replaced|access_denied|policy_blocked|store_error)$ ]] || return 1 + + echo "${trust_status}" +} + +function installWindowsDohTemplate () { + local service_domain="${1}" + local state_output state script_path install_status + + state_output="$(getWindowsDohTemplateState "${service_domain}")" || return 1 + state="$(getWindowsStatusValue "${state_output}" "State")" + + if [[ "${state}" == "present" ]]; then + echo "==> Windows DoH template already registered for 127.0.0.1" + sendWindowsNotification "Warden DoH" "Windows DNS over HTTPS is already registered for 127.0.0.1." "Info" + return 0 + fi + + echo "==> Registering Windows DoH template for 127.0.0.1" + script_path="$(toWindowsPath "${WARDEN_DIR}/utils/windows/install-doh-template-elevated.ps1")" || return 1 + install_status="$(runWindowsPowerShellScript "${script_path}" \ + -ServerAddress "127.0.0.1" \ + -DohTemplate "https://doh.${service_domain}/dns-query" \ + -AllowFallbackToUdp 0 \ + -AutoUpgrade 1)" || return 1 + + case "${install_status}" in + installed) + echo "==> Windows DoH template registered for 127.0.0.1" + sendWindowsNotification "Warden DoH" "Windows DNS over HTTPS was registered for 127.0.0.1." "Info" + ;; + updated) + echo "==> Windows DoH template updated for 127.0.0.1" + sendWindowsNotification "Warden DoH" "Windows DNS over HTTPS was updated for 127.0.0.1." "Info" + ;; + elevation_cancelled) + warning "Administrator approval was canceled while registering the Warden Windows DoH template." + sendWindowsNotification "Warden DoH" "Administrator approval was canceled while registering Windows DNS over HTTPS." "Warning" + ;; + elevation_failed) + warning "Unable to register the Warden Windows DoH template automatically." + sendWindowsNotification "Warden DoH" "Unable to register Windows DNS over HTTPS automatically." "Warning" + ;; + *) + return 1 + ;; + esac +} + +function installWindowsGlobalHosts () { + local service_domain="${1}" + local state_output state script_path install_status + local host_entries=() + local host_entries_text + + state_output="$(getWindowsManagedHostsState "${service_domain}")" || return 1 + state="$(getWindowsStatusValue "${state_output}" "State")" + + if [[ "${state}" == "present" ]]; then + echo "==> Windows hosts entries already present for Warden global services" + sendWindowsNotification "Warden Hosts" "Windows hosts entries are already present for Warden global services." "Info" + return 0 + fi + + while IFS= read -r entry; do + host_entries+=("${entry}") + done < <(getWindowsGlobalHostsEntries "${service_domain}") + host_entries_text="$(printf '%s|' "${host_entries[@]}")" + host_entries_text="${host_entries_text%|}" + + echo "==> Installing Windows hosts entries for Warden global services" + script_path="$(toWindowsPath "${WARDEN_DIR}/utils/windows/install-managed-hosts-elevated.ps1")" || return 1 + install_status="$(runWindowsPowerShellScript "${script_path}" \ + -BlockStart "${WINDOWS_MANAGED_HOSTS_BLOCK_START}" \ + -BlockEnd "${WINDOWS_MANAGED_HOSTS_BLOCK_END}" \ + -EntriesText "${host_entries_text}")" || return 1 + + case "${install_status}" in + installed) + echo "==> Windows hosts entries installed for Warden global services" + sendWindowsNotification "Warden Hosts" "Windows hosts entries were installed for Warden global services." "Info" + ;; + updated) + echo "==> Windows hosts entries updated for Warden global services" + sendWindowsNotification "Warden Hosts" "Windows hosts entries were updated for Warden global services." "Info" + ;; + present) + echo "==> Windows hosts entries already present for Warden global services" + sendWindowsNotification "Warden Hosts" "Windows hosts entries are already present for Warden global services." "Info" + ;; + elevation_cancelled) + warning "Administrator approval was canceled while updating the Windows hosts file for Warden." + sendWindowsNotification "Warden Hosts" "Administrator approval was canceled while updating the Windows hosts file for Warden." "Warning" + ;; + elevation_failed) + warning "Unable to update the Windows hosts file for Warden automatically." + sendWindowsNotification "Warden Hosts" "Unable to update the Windows hosts file for Warden automatically." "Warning" + ;; + *) + return 1 + ;; + esac +} + +function trustRootCaInWindowsLocalMachineElevated () { + local cert_path="${1}" + local windows_cert_path cert_thumbprint elevate_status + local elevated_script_path import_script_path + + [[ -f "${cert_path}" ]] || return 1 + + windows_cert_path="$(wslpath -w "${cert_path}")" || return 1 + cert_thumbprint="$(getWindowsCertificateThumbprint "${cert_path}")" || return 1 + elevated_script_path="$(toWindowsPath "${WARDEN_DIR}/utils/windows/import-root-localmachine-elevated.ps1")" || return 1 + import_script_path="$(toWindowsPath "${WARDEN_DIR}/utils/windows/import-root-localmachine.ps1")" || return 1 + + elevate_status="$(runWindowsPowerShellScript "${elevated_script_path}" \ + -CertificatePath "${windows_cert_path}" \ + -Thumbprint "${cert_thumbprint}" \ + -ImportScriptPath "${import_script_path}")" || return 1 + [[ "${elevate_status}" =~ ^(imported|elevation_cancelled|elevation_failed|policy_blocked)$ ]] || return 1 + + echo "${elevate_status}" +} + +function trustRootCaInWindows () { + local cert_path="${1}" + local local_machine_status elevated_status current_user_status + + local_machine_status="$(trustRootCaInWindowsStore "${cert_path}" "LocalMachine")" || return 1 + + if [[ "${local_machine_status}" == "policy_blocked" ]] || [[ "${local_machine_status}" == "store_error" ]]; then + current_user_status="$(trustRootCaInWindowsStore "${cert_path}" "CurrentUser")" || return 1 + echo "localmachine_${local_machine_status}_${current_user_status}" + elif [[ "${local_machine_status}" == "access_denied" ]]; then + elevated_status="$(trustRootCaInWindowsLocalMachineElevated "${cert_path}")" || return 1 + if [[ "${elevated_status}" == "imported" ]]; then + echo "localmachine_imported_via_elevation" + return 0 + fi + + current_user_status="$(trustRootCaInWindowsStore "${cert_path}" "CurrentUser")" || return 1 + echo "localmachine_${elevated_status}_${current_user_status}" + else + echo "localmachine_${local_machine_status}" + fi +} + +function installWindowsRootCa () { + local cert_path="${1}" + local windows_trust_status + + echo "==> Trusting root certificate in Windows Root store" + if ! windows_trust_status="$(trustRootCaInWindows "${cert_path}")"; then + warning "Unable to trust the Warden root certificate in Windows. Windows browsers may continue to warn until it is imported manually." + sendWindowsNotification "Warden Certificate" "Unable to trust the Warden root certificate in Windows. Manual import may still be required." "Error" + return 0 + fi + + case "${windows_trust_status}" in + localmachine_present) + echo "==> Root certificate already present in Windows LocalMachine Root store" + ;; + localmachine_imported) + echo "==> Root certificate imported into Windows LocalMachine Root store" + sendWindowsNotification "Warden Certificate" "Warden root certificate installed in Windows LocalMachine Root." "Info" + ;; + localmachine_replaced) + echo "==> Root certificate replaced in Windows LocalMachine Root store" + sendWindowsNotification "Warden Certificate" "Warden root certificate was rotated in Windows LocalMachine Root." "Info" + ;; + localmachine_imported_via_elevation) + echo "==> Root certificate imported into Windows LocalMachine Root store after administrator approval" + sendWindowsNotification "Warden Certificate" "Warden root certificate installed in Windows LocalMachine Root after administrator approval." "Info" + ;; + localmachine_policy_blocked_present) + warning "Windows policy may be preventing installation of the Warden root certificate into Windows LocalMachine Root. The certificate is already present in Windows CurrentUser Root store. Contact your administrator if Windows system services still reject the certificate." + sendWindowsNotification "Warden Certificate" "Windows policy may be preventing LocalMachine Root installation. The certificate is present in CurrentUser Root." "Warning" + ;; + localmachine_policy_blocked_imported) + warning "Windows policy may be preventing installation of the Warden root certificate into Windows LocalMachine Root. Imported into Windows CurrentUser Root store instead. Contact your administrator if Windows system services still reject the certificate." + sendWindowsNotification "Warden Certificate" "Windows policy may be preventing LocalMachine Root installation. The certificate was imported into CurrentUser Root instead." "Warning" + ;; + localmachine_policy_blocked_replaced) + warning "Windows policy may be preventing installation of the Warden root certificate into Windows LocalMachine Root. Replaced in Windows CurrentUser Root store instead. Contact your administrator if Windows system services still reject the certificate." + sendWindowsNotification "Warden Certificate" "Windows policy may be preventing LocalMachine Root installation. The certificate was rotated in CurrentUser Root instead." "Warning" + ;; + localmachine_store_error_present) + warning "Windows rejected installation of the Warden root certificate into Windows LocalMachine Root for a reason other than access denial. The certificate is already present in Windows CurrentUser Root store. Windows policy or endpoint security may be blocking this operation." + sendWindowsNotification "Warden Certificate" "Windows rejected LocalMachine Root installation. The certificate is present in CurrentUser Root." "Warning" + ;; + localmachine_store_error_imported) + warning "Windows rejected installation of the Warden root certificate into Windows LocalMachine Root for a reason other than access denial. Imported into Windows CurrentUser Root store instead. Windows policy or endpoint security may be blocking this operation." + sendWindowsNotification "Warden Certificate" "Windows rejected LocalMachine Root installation. The certificate was imported into CurrentUser Root instead." "Warning" + ;; + localmachine_store_error_replaced) + warning "Windows rejected installation of the Warden root certificate into Windows LocalMachine Root for a reason other than access denial. Replaced in Windows CurrentUser Root store instead. Windows policy or endpoint security may be blocking this operation." + sendWindowsNotification "Warden Certificate" "Windows rejected LocalMachine Root installation. The certificate was rotated in CurrentUser Root instead." "Warning" + ;; + localmachine_elevation_cancelled_present) + warning "Administrator approval was canceled while importing the Warden root certificate into Windows LocalMachine Root. The certificate is already present in Windows CurrentUser Root store." + sendWindowsNotification "Warden Certificate" "Administrator approval was canceled. The certificate is already present in Windows CurrentUser Root." "Warning" + ;; + localmachine_elevation_cancelled_imported) + warning "Administrator approval was canceled while importing the Warden root certificate into Windows LocalMachine Root. Imported into Windows CurrentUser Root store instead." + sendWindowsNotification "Warden Certificate" "Administrator approval was canceled. The certificate was imported into Windows CurrentUser Root instead." "Warning" + ;; + localmachine_elevation_cancelled_replaced) + warning "Administrator approval was canceled while importing the Warden root certificate into Windows LocalMachine Root. Replaced in Windows CurrentUser Root store instead." + sendWindowsNotification "Warden Certificate" "Administrator approval was canceled. The certificate was rotated in Windows CurrentUser Root instead." "Warning" + ;; + localmachine_elevation_failed_present) + warning "Administrator-approved import into Windows LocalMachine Root did not complete successfully. The certificate is already present in Windows CurrentUser Root store." + sendWindowsNotification "Warden Certificate" "Administrator-approved import into Windows LocalMachine Root did not complete. The certificate is already present in Windows CurrentUser Root." "Warning" + ;; + localmachine_elevation_failed_imported) + warning "Administrator-approved import into Windows LocalMachine Root did not complete successfully. Imported into Windows CurrentUser Root store instead." + sendWindowsNotification "Warden Certificate" "Administrator-approved import into Windows LocalMachine Root did not complete. The certificate was imported into Windows CurrentUser Root instead." "Warning" + ;; + localmachine_elevation_failed_replaced) + warning "Administrator-approved import into Windows LocalMachine Root did not complete successfully. Replaced in Windows CurrentUser Root store instead." + sendWindowsNotification "Warden Certificate" "Administrator-approved import into Windows LocalMachine Root did not complete. The certificate was rotated in Windows CurrentUser Root instead." "Warning" + ;; + localmachine_policy_blocked_unreadable|localmachine_store_error_unreadable) + warning "Windows policy or endpoint security may be preventing Warden from checking or updating Windows CurrentUser Root after the LocalMachine Root install was blocked." + ;; + esac +} diff --git a/utils/windows/show-notification.ps1 b/utils/windows/show-notification.ps1 new file mode 100644 index 00000000..a27a7bc1 --- /dev/null +++ b/utils/windows/show-notification.ps1 @@ -0,0 +1,37 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Title, + + [Parameter(Mandatory = $true)] + [string]$Message, + + [ValidateSet('Info', 'Warning', 'Error')] + [string]$Level = 'Info' +) + +$ErrorActionPreference = 'Stop' + +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +$notifyIcon = New-Object System.Windows.Forms.NotifyIcon + +try { + $notifyIcon.Icon = switch ($Level) { + 'Warning' { [System.Drawing.SystemIcons]::Warning } + 'Error' { [System.Drawing.SystemIcons]::Error } + default { [System.Drawing.SystemIcons]::Information } + } + $notifyIcon.BalloonTipIcon = switch ($Level) { + 'Warning' { [System.Windows.Forms.ToolTipIcon]::Warning } + 'Error' { [System.Windows.Forms.ToolTipIcon]::Error } + default { [System.Windows.Forms.ToolTipIcon]::Info } + } + $notifyIcon.BalloonTipTitle = $Title + $notifyIcon.BalloonTipText = $Message + $notifyIcon.Visible = $true + $notifyIcon.ShowBalloonTip(5000) + Start-Sleep -Seconds 6 +} finally { + $notifyIcon.Dispose() +} diff --git a/utils/windows/test-dns-resolution.ps1 b/utils/windows/test-dns-resolution.ps1 new file mode 100644 index 00000000..e7c50ad2 --- /dev/null +++ b/utils/windows/test-dns-resolution.ps1 @@ -0,0 +1,21 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Hostname +) + +$ErrorActionPreference = 'Stop' + +try { + $result = Resolve-DnsName -Name $Hostname -DnsOnly -ErrorAction Stop | Select-Object -First 1 + Write-Output 'State=resolved' + + if ($result.NameHost) { + Write-Output "Result=$($result.NameHost)" + } elseif ($result.IPAddress) { + Write-Output "Result=$($result.IPAddress)" + } +} catch { + $message = $_.Exception.Message.Trim() + Write-Output 'State=failed' + Write-Output "Message=$message" +} diff --git a/utils/windows/trust-root-store.ps1 b/utils/windows/trust-root-store.ps1 new file mode 100644 index 00000000..f9796414 --- /dev/null +++ b/utils/windows/trust-root-store.ps1 @@ -0,0 +1,89 @@ +param( + [Parameter(Mandatory = $true)] + [string]$CertificatePath, + + [Parameter(Mandatory = $true)] + [ValidateSet('CurrentUser', 'LocalMachine')] + [string]$StoreLocation +) + +$ErrorActionPreference = 'Stop' + +function Test-AccessDeniedError { + param($ErrorRecord) + + return ( + $ErrorRecord.Exception.HResult -eq -2147024891 -or + $ErrorRecord.Exception.Message -match 'Access is denied' + ) +} + +function Test-PolicyBlockedError { + param($ErrorRecord) + + return $ErrorRecord.Exception.Message -match 'group policy|policy|administrator has blocked|managed by your organization' +} + +if (-not (Test-Path $CertificatePath)) { + throw "Certificate path not found: $CertificatePath" +} + +$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($CertificatePath) +$store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root', $StoreLocation) + +try { + try { + $store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) + } catch [System.Security.Cryptography.CryptographicException] { + if (Test-AccessDeniedError $_) { + Write-Output 'access_denied' + exit 0 + } + if (Test-PolicyBlockedError $_) { + Write-Output 'policy_blocked' + exit 0 + } + Write-Output 'store_error' + exit 0 + } + + try { + $existing = $store.Certificates | Where-Object { $_.Thumbprint -eq $cert.Thumbprint } + if ($existing) { + Write-Output 'present' + exit 0 + } + + $staleWardenRoots = @( + $store.Certificates | Where-Object { + $_.Thumbprint -ne $cert.Thumbprint -and + $_.Subject -like '*O=Warden.dev*' -and + $_.Subject -like '*CN=Warden Proxy Local CA*' + } + ) + + $store.Add($cert) + + foreach ($staleCert in $staleWardenRoots) { + $store.Remove($staleCert) + } + + if ($staleWardenRoots.Count -gt 0) { + Write-Output 'replaced' + } else { + Write-Output 'imported' + } + } catch [System.Security.Cryptography.CryptographicException] { + if (Test-AccessDeniedError $_) { + Write-Output 'access_denied' + } elseif (Test-PolicyBlockedError $_) { + Write-Output 'policy_blocked' + } else { + Write-Output 'store_error' + } + } catch { + Write-Output 'store_error' + } +} finally { + $store.Close() +}