Skip to content
145 changes: 88 additions & 57 deletions scripts/container-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ caddyfile_cleartext=/etc/caddy/Caddyfile-http
caddyfile_https=/etc/caddy/Caddyfile-https
headscale_config="/etc/headscale/config.yaml"

# Defaults used throughout the script
public_listen_port_default=443
headscale_extra_records_path_default="/data/headscale/extra-records.json"
headscale_magic_dns_default="true"
headscale_ipv6_prefix_default="fd7a:115c:a1e0::/48"
headscale_ipv4_prefix_default="100.64.0.0/10"
headscale_ip_allocation_default="sequential"
headscale_gomaxprocs_default=1

# Caddyfile block placeholders
ACME_EAB_BLOCK=""
CLOUDFLARE_ACME_BLOCK=""
Expand Down Expand Up @@ -78,19 +87,22 @@ log_error() {
}

#######################################
# Check if an environment variable is populated
# Check if an environment variable is defined. This explicitly includes `null` and `empty string`.
# Arguments:
# $1 - Variable name
# Returns:
# `true` if populated, otherwise `false`
# `true` if defined, otherwise `false`
#######################################
env_var_is_populated() {
# Only allow variable names with letters, numbers, and underscores, not starting with a number
if [[ "${1}" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
[[ -n "${!1-}" ]]
else
log_error "Invalid environment variable name: '${1}'"
fi
env_var_is_defined() {
# Only allow variable names with letters, numbers, and underscores, not starting with a number
if ! [[ "${1}" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
log_error "Invalid environment variable name: '${1}'"
return
fi

# Consider a variable defined if it is set in the environment, even if the value is an empty string.
# ${param+word} expands to 'word' when the parameter is set (even if null), otherwise empty.
[[ "${!1+set}" == "set" ]]
}

#######################################
Expand All @@ -101,7 +113,7 @@ env_var_is_populated() {
# `true` if populated, otherwise `false`
#######################################
require_env_var() {
env_var_is_populated "${1}" || log_error "Environment variable '${1}' is required"
env_var_is_defined "${1}" || log_error "Environment variable '${1}' is required"
}

########################################
Expand Down Expand Up @@ -133,7 +145,7 @@ check_env_var_or_set_default() {
local error_msg="${4:-}"

# Set default value if variable is not populated
if ! env_var_is_populated "${var_name}"; then
if ! env_var_is_defined "${var_name}"; then
export "${var_name}"="${default_value}"
fi

Expand Down Expand Up @@ -232,7 +244,7 @@ create_config_from_template() {
# Set default or validate PUBLIC_LISTEN_PORT
#######################################
check_public_listen_port() {
check_env_var_or_set_default "PUBLIC_LISTEN_PORT" "443"
check_env_var_or_set_default "PUBLIC_LISTEN_PORT" "${public_listen_port_default}"
validate_port "PUBLIC_LISTEN_PORT"
}

Expand Down Expand Up @@ -294,8 +306,8 @@ autodetect_gomaxprocs() {
# `true` on success, `false` on error
#######################################
configure_gomaxprocs() {
if env_var_is_populated "GOMAXPROCS"; then
check_env_var_or_set_default "GOMAXPROCS" "1" "^[1-9][0-9]*$" "Invalid 'GOMAXPROCS'. Must be a positive integer."
if env_var_is_defined "GOMAXPROCS"; then
check_env_var_or_set_default "GOMAXPROCS" "${headscale_gomaxprocs_default}" "^[1-9][0-9]*$" "Invalid 'GOMAXPROCS'. Must be a positive integer."
else
autodetect_gomaxprocs
fi
Expand Down Expand Up @@ -330,7 +342,7 @@ check_litestream_replica_url() {
# Validate OIDC settings
#######################################
validate_oidc_settings() {
if ! env_var_is_populated "HEADSCALE_OIDC_ISSUER"; then
if ! env_var_is_defined "HEADSCALE_OIDC_ISSUER"; then
log_info "OIDC is not enabled, skipping OIDC validation."
return
fi
Expand All @@ -343,7 +355,7 @@ validate_oidc_settings() {
# Validate extra DNS records settings
#######################################
validate_extra_records() {
check_env_var_or_set_default "HEADSCALE_EXTRA_RECORDS_PATH" "/data/headscale/extra-records.json"
check_env_var_or_set_default "HEADSCALE_EXTRA_RECORDS_PATH" "${headscale_extra_records_path_default}"

# Ensure the directory exists
local records_dir
Expand Down Expand Up @@ -375,42 +387,81 @@ check_headscale_environment_vars() {
check_litestream_replica_url
validate_oidc_settings
validate_extra_records
check_env_var_or_set_default "MAGIC_DNS" "true" "^(true|false)$" "Invalid 'MAGIC_DNS'. Must be 'true' or 'false'."
check_env_var_or_set_default "IPV6_PREFIX" "fd7a:115c:a1e0::/48"
check_env_var_or_set_default "IPV4_PREFIX" "100.64.0.0/10"
check_env_var_or_set_default "IP_ALLOCATION" "sequential" "^(sequential|random)$" "Invalid 'IP_ALLOCATION'. Must be either 'sequential' (default) or 'random'."
check_env_var_or_set_default "MAGIC_DNS" "${headscale_magic_dns_default}" "^(true|false)$" "Invalid 'MAGIC_DNS'. Must be 'true' or 'false'."
check_env_var_or_set_default "IPV6_PREFIX" "${headscale_ipv6_prefix_default}"
check_env_var_or_set_default "IPV4_PREFIX" "${headscale_ipv4_prefix_default}"
check_env_var_or_set_default "IP_ALLOCATION" "${headscale_ip_allocation_default}" "^(sequential|random)$" "Invalid 'IP_ALLOCATION'. Must be either 'sequential' (default) or 'random'."
require_env_var "PUBLIC_SERVER_URL"
require_env_var "HEADSCALE_DNS_BASE_DOMAIN"
#This is for the v0.26.0 bump.
if env_var_is_populated "HEADSCALE_POLICY_V1" ; then
if env_var_is_defined "HEADSCALE_POLICY_V1" ; then
export HEADSCALE_POLICY_V1=1
log_warn "Using Headscale policy version 1. Please migrate and remove this variable."
fi
}

#######################################
# Create our Headscale configuration file
#######################################
create_headscale_config() {
# Ensure all template variables are exported for envsubst
local template_vars=(
"ACME_EAB_BLOCK"
"CLOUDFLARE_ACME_BLOCK"
"SECURITY_HEADERS_BLOCK"
"PUBLIC_LISTEN_PORT"
"MAGIC_DNS"
"IPV6_PREFIX"
"IPV4_PREFIX"
"IP_ALLOCATION"
"HEADSCALE_EXTRA_RECORDS_PATH"
)
for var in "${template_vars[@]}"; do
export "${var}=${!var}"
done

create_config_from_template "${headscale_config}" "Headscale configuration file"
}

#######################################
# Create our Caddyfile
#######################################
create_caddyfile() {
if ${https_enabled}; then
create_config_from_template "${caddyfile_https}" "Caddy HTTPS configuration file"
else
create_config_from_template "${caddyfile_cleartext}" "Caddy HTTP configuration file"
fi
}

#######################################
# Validate ZeroSSL EAB credentials if provided and modify Caddyfile as needed
#######################################
check_zerossl_eab() {
if env_var_is_populated "ACME_EAB_KEY_ID" || env_var_is_populated "ACME_EAB_MAC_KEY"; then
if env_var_is_defined "ACME_EAB_KEY_ID" || env_var_is_defined "ACME_EAB_MAC_KEY"; then
require_env_var "ACME_EAB_KEY_ID"
require_env_var "ACME_EAB_MAC_KEY"

export ACME_EAB_BLOCK="acme_ca https://acme.zerossl.com/v2/DV90
acme_eab {
key_id ${ACME_EAB_KEY_ID}
mac_key ${ACME_EAB_MAC_KEY}
}"
# Use a heredoc to avoid accidental quoting/escaping issues and preserve formatting
ACME_EAB_BLOCK=$(cat <<EOF
acme_ca https://acme.zerossl.com/v2/DV90
acme_eab {
key_id ${ACME_EAB_KEY_ID}
mac_key ${ACME_EAB_MAC_KEY}
}
EOF
)
export ACME_EAB_BLOCK
else
export ACME_EAB_BLOCK=""
export ACME_EAB_BLOCK=""
fi
}

#######################################
# Validate the Cloudflare API Key if provided and modify Caddyfile as needed
#######################################
check_cloudflare_dns_api_key() {
if env_var_is_populated "CF_API_TOKEN" ; then
if env_var_is_defined "CF_API_TOKEN" ; then
export CLOUDFLARE_ACME_BLOCK="tls {
dns cloudflare ${CF_API_TOKEN}
}"
Expand Down Expand Up @@ -503,7 +554,7 @@ configure_security_headers() {
check_caddy_environment_variables() {
configure_security_headers

if env_var_is_populated "CADDY_FRONTEND" && [[ "${CADDY_FRONTEND}" = "DISABLE_HTTPS" ]]; then
if env_var_is_defined "CADDY_FRONTEND" && [[ "${CADDY_FRONTEND}" = "DISABLE_HTTPS" ]]; then
https_enabled=false
return
fi
Expand Down Expand Up @@ -543,7 +594,7 @@ reuse_or_create_noise_private_key() {
return
fi

if env_var_is_populated "HEADSCALE_NOISE_PRIVATE_KEY"; then
if env_var_is_defined "HEADSCALE_NOISE_PRIVATE_KEY"; then
printf '%s' "${HEADSCALE_NOISE_PRIVATE_KEY}" > "${key_path}"
chmod 600 "${key_path}"
else
Expand All @@ -559,29 +610,9 @@ check_config_files() {

check_caddy_environment_variables

# Ensure all template variables are exported for envsubst
local template_vars=(
"ACME_EAB_BLOCK"
"CLOUDFLARE_ACME_BLOCK"
"SECURITY_HEADERS_BLOCK"
"PUBLIC_LISTEN_PORT"
"MAGIC_DNS"
"IPV6_PREFIX"
"IPV4_PREFIX"
"IP_ALLOCATION"
"HEADSCALE_EXTRA_RECORDS_PATH"
)
for var in "${template_vars[@]}"; do
export "${var}=${!var}"
done

create_config_from_template "${headscale_config}" "Headscale configuration file"
create_headscale_config

if ${https_enabled}; then
create_config_from_template "${caddyfile_https}" "Caddy HTTPS configuration file"
else
create_config_from_template "${caddyfile_cleartext}" "Caddy HTTP configuration file"
fi
create_caddyfile

reuse_or_create_noise_private_key
}
Expand All @@ -608,22 +639,22 @@ display_configuration_summary() {
log_info "IPv4 Prefix: ${IPV4_PREFIX}"
log_info "IPv6 Prefix: ${IPV6_PREFIX}"

if env_var_is_populated "HEADSCALE_OIDC_ISSUER"; then
if env_var_is_defined "HEADSCALE_OIDC_ISSUER"; then
log_feature_status "OIDC" true "${HEADSCALE_OIDC_ISSUER}"
if env_var_is_populated "HEADSCALE_OIDC_EXTRA_PARAMS_DOMAIN_HINT"; then
if env_var_is_defined "HEADSCALE_OIDC_EXTRA_PARAMS_DOMAIN_HINT"; then
log_feature_status "OIDC Domain Hint" true "${HEADSCALE_OIDC_EXTRA_PARAMS_DOMAIN_HINT}"
else
log_feature_status "OIDC Domain Hint" false ""
fi
fi

if ${https_enabled}; then
if env_var_is_populated "CF_API_TOKEN"; then
if env_var_is_defined "CF_API_TOKEN"; then
log_info "DNS Challenge: Cloudflare"
else
log_info "DNS Challenge: HTTP-01"
fi
if env_var_is_populated "ACME_EAB_KEY_ID"; then
if env_var_is_defined "ACME_EAB_KEY_ID"; then
log_feature_status "ACME EAB" true "ZeroSSL"
else
log_feature_status "ACME EAB" false "Let's Encrypt"
Expand Down
Loading