diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d197779..c090b945 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ **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 +* `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 7f2486d2..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,6 +67,12 @@ then -k /Library/Keychains/System.keychain "${WARDEN_SSL_DIR}/rootca/certs/ca.cert.pem" 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 ## for BSD like per-TLD configuration as is done at /etc/resolver/test on Mac if [[ "$OSTYPE" == "darwin"* ]]; then @@ -93,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 dc80e05a..fe06e3e2 100644 --- a/utils/install.sh +++ b/utils/install.sh @@ -1,6 +1,12 @@ #!/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" + +# 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 echo "==> Configuring sshd tunnel in host ssh_config (requires sudo privileges)" 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() +}