diff --git a/Dockerfile b/Dockerfile index a30d2f6..718fafc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,30 +1,29 @@ -# --- -# Tool version args -# Bump these every time there is a new release. Don't forget the checksum! +################### +# BUILD PREP +################### +# Tool version arguments +# Bump these every time there is a new release. +# We're pulling these from github source, don't forget to bump the checksum! ARG HEADSCALE_VERSION="0.26.1" ARG HEADSCALE_SHA256="5012577e6fc5d4234aab7b4be0d6e271ea1a4ec38521a8aa472f80ea1fe81cba" ARG LITESTREAM_VERSION="0.3.13" ARG LITESTREAM_SHA256="eb75a3de5cab03875cdae9f5f539e6aedadd66607003d9b1e7a9077948818ba0" -# --- -# Container version args -# Bump these every time there is a new release. No checksum needed. +# No checksum needed for these tools, we pull from official images ARG CADDY_VERSION="2.10.0" ARG MAIN_IMAGE_ALPINE_VERSION="3.22.0" ARG HEADSCALE_ADMIN_VERSION="dev" -# --- -# Tool download links +# github download links # These should never need adjusting unless the URIs change ARG HEADSCALE_DOWNLOAD_URL="https://github.com/juanfont/headscale/releases/download/v${HEADSCALE_VERSION}/headscale_${HEADSCALE_VERSION}_linux_amd64" ARG LITESTREAM_DOWNLOAD_URL="https://github.com/benbjohnson/litestream/releases/download/v${LITESTREAM_VERSION}/litestream-v${LITESTREAM_VERSION}-linux-amd64.tar.gz" -########### -# LOGIC STARTS HERE -########### +################### +# BUILD PROCESS +################### -# --- # Build caddy with Cloudflare DNS support FROM caddy:${CADDY_VERSION}-builder AS caddy-builder # Set SHELL flags for RUN commands to allow -e and pipefail @@ -34,46 +33,40 @@ FROM caddy:${CADDY_VERSION}-builder AS caddy-builder RUN xcaddy build \ --with github.com/caddy-dns/cloudflare -# --- # Docker hates variables in COPY, apparently. Hello, workaround. FROM goodieshq/headscale-admin:${HEADSCALE_ADMIN_VERSION} AS admin-gui -# --- # Build our main image FROM alpine:${MAIN_IMAGE_ALPINE_VERSION} # Set SHELL flags for RUN commands to allow -e and pipefail # Rationale: https://github.com/hadolint/hadolint/wiki/DL4006 SHELL ["/bin/ash", "-eo", "pipefail", "-c"] - # --- - # import our "global" `ARG` values into this stage + # Import our "global" `ARG` values into this stage ARG HEADSCALE_DOWNLOAD_URL ARG HEADSCALE_SHA256 ARG LITESTREAM_DOWNLOAD_URL ARG LITESTREAM_SHA256 - # --- # Upgrade system and install various dependencies # - BusyBox's wget isn't reliable enough # - I'm gonna need a better shell # - We need GNU sed # hadolint ignore=DL3018,SC2086 RUN BUILD_DEPS="wget"; \ - RUNTIME_DEPS="bash sed"; \ + RUNTIME_DEPS="bash sed gettext"; \ apk --no-cache upgrade; \ apk add --no-cache --virtual BuildTimeDeps ${BUILD_DEPS}; \ apk add --no-cache ${RUNTIME_DEPS} - # --- # Copy caddy from the first stage COPY --from=caddy-builder /usr/bin/caddy /usr/local/bin/caddy # Caddy smoke test RUN [ "$(command -v caddy)" = '/usr/local/bin/caddy' ]; \ caddy version - # --- # Headscale - RUN { \ + RUN set -ex; { \ wget --retry-connrefused \ --waitretry=1 \ --read-timeout=20 \ @@ -81,18 +74,20 @@ FROM alpine:${MAIN_IMAGE_ALPINE_VERSION} -t 0 \ -q \ -O headscale \ - ${HEADSCALE_DOWNLOAD_URL} \ - ; \ + ${HEADSCALE_DOWNLOAD_URL} || { \ + echo "Failed to download Headscale from ${HEADSCALE_DOWNLOAD_URL}"; \ + exit 1; \ + }; \ echo "${HEADSCALE_SHA256} *headscale" | sha256sum -c - >/dev/null 2>&1; \ chmod +x headscale; \ mv headscale /usr/local/bin/; \ }; \ - # smoke test + # Headscale smoke test [ "$(command -v headscale)" = '/usr/local/bin/headscale' ]; \ headscale version; # Litestream - RUN { \ + RUN set -ex; { \ wget --retry-connrefused \ --waitretry=1 \ --read-timeout=20 \ @@ -107,7 +102,7 @@ FROM alpine:${MAIN_IMAGE_ALPINE_VERSION} mv litestream /usr/local/bin/; \ rm -f litestream.tar.gz; \ }; \ - # smoke test + # Litestream smoke test [ "$(command -v litestream)" = '/usr/local/bin/litestream' ]; \ litestream version; @@ -117,13 +112,16 @@ FROM alpine:${MAIN_IMAGE_ALPINE_VERSION} # Remove build-time dependencies RUN apk del BuildTimeDeps - # --- - # copy configuration and templates + # Copy configuration templates COPY ./templates/headscale.template.yaml /etc/headscale/config.yaml COPY ./templates/litestream.template.yml /etc/litestream.yml COPY ./templates/caddy.http.template.yaml /etc/caddy/Caddyfile-http COPY ./templates/caddy.https.template.yaml /etc/caddy/Caddyfile-https - COPY ./scripts/container-entrypoint.sh /container-entrypoint.sh - RUN chmod +x /container-entrypoint.sh + + # Copy and setup entrypoint script + COPY --chmod=755 ./scripts/container-entrypoint.sh /container-entrypoint.sh + + # Default HTTPS port - override with $PUBLIC_LISTEN_PORT environment variable + EXPOSE 443 ENTRYPOINT ["/container-entrypoint.sh"] diff --git a/docs/index.md b/docs/index.md index b95b99f..ceeb9b9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,4 +28,4 @@ Details about configuration options. ## Contributing -Guidelines for contributing to the project. \ No newline at end of file +Guidelines for contributing to the project. diff --git a/scripts/container-entrypoint.sh b/scripts/container-entrypoint.sh index 036d6a1..b99ea9d 100755 --- a/scripts/container-entrypoint.sh +++ b/scripts/container-entrypoint.sh @@ -1,6 +1,6 @@ #!/bin/bash -set -e +set -euo pipefail # Global flags abort_config=false @@ -8,16 +8,52 @@ litestream_disabled=false cleartext_only=false caddyfile_cleartext=/etc/caddy/Caddyfile-http caddyfile_https=/etc/caddy/Caddyfile-https +headscale_config="/etc/headscale/config.yaml" +ACME_EAB_BLOCK="" # Placeholder for ACME EAB block in Caddyfile +CLOUDFLARE_ACME_BLOCK="" # Placeholder for Cloudflare ACME block in Caddyfile + +####################################### +# Log with different levels +# Arguments: +# $1 - Log level (INFO, WARN, ERROR) +# $2 - Message to log +####################################### +log_with_level() { + local level="$1" + local message="$2" + local timestamp; + + timestamp=$(date +"%Y-%m-%d %H:%M:%S") + + case "${level^^}" in + ERROR) + echo "[$timestamp] ERROR: $message" >&2 + ;; + WARN) + echo "[$timestamp] WARN: $message" >&2 + ;; + *) + echo "[$timestamp] INFO: $message" + ;; + esac +} ####################################### # Log an informational message # Arguments: # `$1` - Message to log -# Ouputs: -# Message to `STDOUT` ####################################### log_info() { - echo "INFO: $1" + log_with_level "INFO" "$1" +} + +####################################### +# Log a warning message +# Arguments: +# `$1` - Message to log +####################################### +log_warn() { + log_with_level "WARN" "$1" } ####################################### @@ -28,13 +64,11 @@ log_info() { # `abort_config` # Returns: # `false` -# Ouputs: -# Message to `STDERR` ####################################### log_error() { - echo >&2 "ERROR: $1" - abort_config=true - false + log_with_level "ERROR" "$1" + abort_config=true + false } ####################################### @@ -45,7 +79,12 @@ log_error() { # `true` if populated, otherwise `false` ####################################### env_var_is_populated() { - [ -n "${!1}" ] + # 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 } ####################################### @@ -69,15 +108,59 @@ require_env_var() { # `true` if deemed valid, otherwise `false` ####################################### validate_port() { - port="$1" - case "${!port}" in - '' | *[!0123456789]*) log_error "'$port' is not numeric." && return ;; - 0*[!0]*) log_error "'$port' has a leading zero." && return ;; - esac + port="$1" + value="${!port}" + + # Make sure our port is numeric + if ! [[ "$value" =~ ^[0-9]+$ ]]; then + log_error "Port '$port' is not numeric." && return + fi + + # Check no leading zeros (except for port '0') + if [[ "$value" =~ ^0[0-9]+$ ]]; then + log_error "Port '$port' has a leading zero." && return + fi + + # Check port is within valid range + if [ "$value" -lt 1 ] || [ "$value" -gt 65535 ]; then + log_error "Port '$port' must be a valid port within the range of 1-65535." && return + fi +} - if [ "${!port}" -lt 1 ] || [ "${!port}" -gt 65535 ] ; then - log_error "'$port' must be a valid port within the range of 1-65535." && return - fi +####################################### +# Generic configuration file creator with template substitution +# Arguments: +# $1 - Target config file path +# $2 - Description for logging +# $3 - File permissions (optional, defaults to 600) +####################################### +create_config_from_template() { + local config_path="$1" + local description="$2" + local permissions="${3:-600}" + local temp_config_path + + temp_config_path=$(mktemp) || { + log_error "Unable to create temporary file for $description" + return + } + + log_info "Generating $description..." + + if envsubst < "$config_path" > "$temp_config_path"; then + chmod "$permissions" "$temp_config_path" + if mv "$temp_config_path" "$config_path"; then + log_info "$description created successfully" + else + log_error "Unable to move $description to final location" + rm -f "$temp_config_path" + fi + else + log_error "Unable to generate $description" + rm -f "$temp_config_path" + fi + + return } ####################################### @@ -197,20 +280,7 @@ check_required_environment_vars() { # Create Headscale configuration file ####################################### create_headscale_config() { - local config_path="/etc/headscale/config.yaml" - - log_info "Generating Headscale configuration file..." - - sed -i \ - -e "s@\$PUBLIC_SERVER_URL@$PUBLIC_SERVER_URL@" \ - -e "s@\$HEADSCALE_LISTEN_ADDRESS@$HEADSCALE_LISTEN_ADDRESS@" \ - -e "s@\$PUBLIC_LISTEN_PORT@$PUBLIC_LISTEN_PORT@" \ - -e "s@\$IPV6_PREFIX@$IPV6_PREFIX@" \ - -e "s@\$IPV4_PREFIX@$IPV4_PREFIX@" \ - -e "s@\$IP_ALLOCATION@$IP_ALLOCATION@" \ - -e "s@\$HEADSCALE_DNS_CONFIG_BASE_DOMAIN@$HEADSCALE_DNS_CONFIG_BASE_DOMAIN@" \ - -e "s@\$MAGIC_DNS@$MAGIC_DNS@" \ - "$config_path" || log_error "Unable to generate Headscale configuration file" + create_config_from_template "$headscale_config" "Headscale configuration file" } ####################################### @@ -221,12 +291,14 @@ reuse_or_create_noise_private_key() { if [ -f "$key_path" ]; then log_info "Using existing private Noise key on disk." + chmod 600 "$key_path" return fi if env_var_is_populated "HEADSCALE_NOISE_PRIVATE_KEY"; then log_info "Using provided private Noise key from environment variable." - echo -n "$HEADSCALE_NOISE_PRIVATE_KEY" > "$key_path" + printf '%s' "$HEADSCALE_NOISE_PRIVATE_KEY" > "$key_path" + chmod 600 "$key_path" else log_info "Generating a new private Noise key." fi @@ -241,12 +313,14 @@ check_zerossl_eab() { require_env_var "ACME_EAB_KEY_ID" require_env_var "ACME_EAB_MAC_KEY" - sed -iz \ - "s@<>@acme_ca https://acme.zerossl.com/v2/DV90\nacme_eab {\n key_id ${ACME_EAB_KEY_ID}\n mac_key ${ACME_EAB_MAC_KEY}\n }@" \ - $caddyfile_https || abort_config=1 + 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} + }" else log_info "No ACME EAB credentials provided" - sed -i "s@<>@@" $caddyfile_https || abort_config=1 + export ACME_EAB_BLOCK="" fi } @@ -254,16 +328,15 @@ check_zerossl_eab() { # 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 - log_info "Using Cloudflare for ACME DNS Challenge." - - sed -iz \ - "s@<>@tls {\n dns cloudflare $CF_API_TOKEN\n }@" \ - $caddyfile_https || abort_config=1 - else - log_info "Using HTTP authentication for ACME DNS Challenge" - sed -i "s@<>@@" $caddyfile_https || abort_config=1 - fi + if env_var_is_populated "CF_API_TOKEN" ; then + log_info "Using Cloudflare for ACME DNS Challenge." + export CLOUDFLARE_ACME_BLOCK="tls { + dns cloudflare ${CF_API_TOKEN} + }" + else + log_info "Using HTTP authentication for ACME DNS Challenge" + export CLOUDFLARE_ACME_BLOCK="" + fi } ####################################### @@ -280,6 +353,13 @@ check_caddy_specific_environment_variables() { check_zerossl_eab } +####################################### +# Create Caddy HTTPS configuration file +####################################### +create_caddy_https_config() { + create_config_from_template "$caddyfile_https" "Caddy HTTPS configuration file" +} + ####################################### # Create our configuration files ####################################### @@ -288,6 +368,8 @@ check_config_files() { check_caddy_specific_environment_variables + create_caddy_https_config + create_headscale_config reuse_or_create_noise_private_key @@ -297,48 +379,51 @@ check_config_files() { # Create required directories ####################################### check_needed_directories() { - mkdir -p /var/run/headscale || return - mkdir -p /data/headscale || return - mkdir -p /data/caddy || return + mkdir -p /var/run/headscale || log_error "Unable to create /var/run/headscale directory." + mkdir -p /data/headscale || log_error "Unable to create /data/headscale directory." + mkdir -p /data/caddy || log_error "Unable to create /data/caddy directory." } ####################################### # Main logic ####################################### run() { - check_needed_directories || log_error "Unable to create required configuration directories." + check_needed_directories - check_config_files || log_error "We don't have enough information to run our services." + check_config_files if ! $abort_config ; then - log_info "Starting Caddy using our environment variables. HTTPS is $([ "$cleartext_only" ] && echo "disabled" || echo "enabled")." + log_info "Starting Caddy using our environment variables. HTTPS is $([ "$cleartext_only" = true ] && echo "disabled" || echo "enabled")." - if $cleartext_only ; then - caddy start --config "$caddyfile_cleartext" + if [ "$cleartext_only" = true ] ; then + caddy start --config "$caddyfile_cleartext" || log_error "Failed to start Caddy with cleartext config" else - caddy start --config "$caddyfile_https" + caddy start --config "$caddyfile_https" || log_error "Failed to start Caddy with HTTPS config" fi - if ! $litestream_disabled ; then - log_info "Attempt to restore previous Headscale database if there's a replica" && \ - litestream restore -if-db-not-exists -if-replica-exists /data/headscale.sqlite3 && \ - \ - log_info "Starting Headscale using Litestream and our Environment Variables..." && \ - litestream replicate -exec 'headscale serve' - else - headscale serve + # Make sure Caddy started successfully before starting headscale + if ! $abort_config ; then + if [ "$litestream_disabled" = false ] ; then + log_info "Attempt to restore previous Headscale database if there's a replica" + litestream restore -if-db-not-exists -if-replica-exists /data/headscale.sqlite3 || + log_warn "No replica found, or unable to restore database." + + log_info "Starting Headscale using Litestream and our Environment Variables..." + exec litestream replicate -exec 'headscale serve' + else + log_info "Starting Headscale without Litestream" + exec headscale serve + fi fi fi - log_error "Something went wrong." - if [ -n "$DEBUG" ] ; then + if [ -n "${DEBUG:-}" ] ; then log_info "Sleeping so you can connect and debug" # Allow us to start a terminal in the container for debugging sleep infinity fi - log_error "Exiting with code ${abort_config}" - exit "$abort_config" + exit 1 } run diff --git a/templates/caddy.https.template.yaml b/templates/caddy.https.template.yaml index 5cbbc51..e50666e 100644 --- a/templates/caddy.https.template.yaml +++ b/templates/caddy.https.template.yaml @@ -3,12 +3,12 @@ root /data/caddy } - email {$ACME_ISSUANCE_EMAIL} + email ${ACME_ISSUANCE_EMAIL} - <> + ${ACME_EAB_BLOCK} } -{$PUBLIC_SERVER_URL}:{$PUBLIC_LISTEN_PORT} { +${PUBLIC_SERVER_URL}:${PUBLIC_LISTEN_PORT} { handle_path /admin* { root * /admin-gui/admin encode gzip zstd @@ -16,9 +16,13 @@ file_server } + handle /admin { + redirect /admin/ + } + handle { reverse_proxy 127.0.0.1:8080 } - <> + ${CLOUDFLARE_ACME_BLOCK} }