diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index bf19d312..e9abaf67 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -9,6 +9,21 @@ on: - main jobs: + test-auto-tls-compose: + name: Test automatic TLS Compose preflight + runs-on: ubuntu-22.04 + permissions: + contents: read + + steps: + - name: Code checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Test automatic TLS port validation + run: sh scripts/test-docker-compose-auto-tls.sh + build-and-test: name: Build and Test runs-on: ${{ matrix.os }} diff --git a/Dockerfile b/Dockerfile index 3e4152a9..d610dd7d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,4 +22,4 @@ COPY --from=builder /etc/os-release /etc/os-release COPY --from=builder /app/wings /usr/bin/ CMD [ "/usr/bin/wings", "--config", "/etc/pelican/config.yml" ] -EXPOSE 8080 +EXPOSE 80 8080 2022 diff --git a/docker-compose.auto-tls.example.yml b/docker-compose.auto-tls.example.yml new file mode 100644 index 00000000..57a79d62 --- /dev/null +++ b/docker-compose.auto-tls.example.yml @@ -0,0 +1,72 @@ +# This opt-in example uses Wings' built-in ACME client to issue and renew a +# Let's Encrypt certificate. The default Compose example remains unchanged. +# It requires exclusive access to port 80 on the published host address. When +# Panel or another reverse proxy shares that address, terminate TLS there and +# proxy to Wings instead, or bind Wings to a separate public address. +# +# Before starting: +# 1. Set WINGS_HOSTNAME to the node FQDN without a scheme or port. +# 2. Set WINGS_API_PORT and WINGS_SFTP_PORT to the same ports configured in +# /etc/pelican/config.yml. Port 80 is reserved for the ACME HTTP-01 challenge. +# 3. Create the Panel node with HTTPS enabled and matching daemon ports. +# 4. Point the FQDN directly at this host. When using Cloudflare, keep the +# record DNS only because the configured SFTP port cannot use the proxy. +# 5. Allow inbound TCP traffic on port 80 and both configured service ports. +# Every published A and AAAA address must reach this host on those ports. +# +# Example .env: +# WINGS_HOSTNAME=node.example.com +# WINGS_API_PORT=8080 +# WINGS_SFTP_PORT=2022 +# +# Validate the configured ports and start the stack through the included +# wrapper. It rejects invalid, reserved, and conflicting service ports before +# Docker Compose attempts to bind them: +# sh ./docker-compose.auto-tls.sh up -d +# +# The existing /var/lib/pelican volume persists the ACME account and certificate +# cache at /var/lib/pelican/.tls-cache across container restarts. + +services: + wings: + image: ghcr.io/pelican/wings:latest + restart: always + command: + - /usr/bin/wings + - --config + - /etc/pelican/config.yml + - --auto-tls + - --tls-hostname + - "${WINGS_HOSTNAME:?Set WINGS_HOSTNAME to the node FQDN}" + networks: + - wings0 + ports: + - "80:80" + - "${WINGS_API_PORT:?Set WINGS_API_PORT to the API port in config.yml}:${WINGS_API_PORT:?Set WINGS_API_PORT to the API port in config.yml}" + - "${WINGS_SFTP_PORT:?Set WINGS_SFTP_PORT to the SFTP port in config.yml}:${WINGS_SFTP_PORT:?Set WINGS_SFTP_PORT to the SFTP port in config.yml}" + tty: true + environment: + TZ: "UTC" + WINGS_UID: 988 + WINGS_GID: 988 + WINGS_USERNAME: pelican + volumes: + - "/var/run/docker.sock:/var/run/docker.sock" + - "/var/lib/docker/containers/:/var/lib/docker/containers/" + - "/etc/pelican/:/etc/pelican/" + - "/var/lib/pelican/:/var/lib/pelican/" + - "/var/log/pelican/:/var/log/pelican/" + - "/tmp/pelican/:/tmp/pelican/" + - "/etc/ssl/certs:/etc/ssl/certs:ro" + # You may need /srv/daemon-data when upgrading from an old daemon. + #- "/srv/daemon-data/:/srv/daemon-data/" + +networks: + wings0: + name: wings0 + driver: bridge + ipam: + config: + - subnet: "172.21.0.0/16" + driver_opts: + com.docker.network.bridge.name: wings0 diff --git a/docker-compose.auto-tls.sh b/docker-compose.auto-tls.sh new file mode 100755 index 00000000..40784894 --- /dev/null +++ b/docker-compose.auto-tls.sh @@ -0,0 +1,132 @@ +#!/bin/sh + +set -eu + +script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +compose_file="$script_dir/docker-compose.auto-tls.example.yml" +env_file='' +env_file_count=0 +check_only=false +expect_env_file=false + +for argument do + if [ "$expect_env_file" = true ]; then + env_file=$argument + env_file_count=$((env_file_count + 1)) + expect_env_file=false + continue + fi + + case $argument in + --env-file) + expect_env_file=true + ;; + --env-file=*) + env_file=${argument#--env-file=} + env_file_count=$((env_file_count + 1)) + ;; + --check) + check_only=true + ;; + esac +done + +if [ "$expect_env_file" = true ]; then + printf '%s\n' 'Automatic TLS preflight failed: --env-file requires a path.' >&2 + exit 1 +fi + +if [ "$env_file_count" -gt 1 ]; then + printf '%s\n' 'Automatic TLS preflight failed: only one --env-file is supported.' >&2 + exit 1 +fi + +if [ "$env_file_count" -eq 1 ] && [ -z "$env_file" ]; then + printf '%s\n' 'Automatic TLS preflight failed: --env-file requires a path.' >&2 + exit 1 +fi + +# Compose, rather than this shell, must expand the interpolation expressions. +# shellcheck disable=SC2016 +compose_probe='services: + preflight: + image: scratch + environment: + WINGS_API_PORT: "${WINGS_API_PORT-}" + WINGS_SFTP_PORT: "${WINGS_SFTP_PORT-}"' + +if [ "$env_file_count" -eq 1 ]; then + if ! compose_environment=$(printf '%s\n' "$compose_probe" | docker compose \ + --project-directory "$script_dir" \ + --env-file "$env_file" \ + -f - \ + config --environment); then + printf '%s\n' 'Automatic TLS preflight failed: Docker Compose could not resolve the environment.' >&2 + exit 1 + fi +else + if ! compose_environment=$(printf '%s\n' "$compose_probe" | docker compose \ + --project-directory "$script_dir" \ + -f - \ + config --environment); then + printf '%s\n' 'Automatic TLS preflight failed: Docker Compose could not resolve the environment.' >&2 + exit 1 + fi +fi + +read_resolved_value() { + variable_name=$1 + + printf '%s\n' "$compose_environment" | awk -v wanted="$variable_name" ' + index($0, wanted "=") == 1 { + print substr($0, length(wanted) + 2) + exit + } + ' +} + +api_port=$(read_resolved_value WINGS_API_PORT) +sftp_port=$(read_resolved_value WINGS_SFTP_PORT) + +validate_port() { + variable_name=$1 + port=$2 + + if [ -z "$port" ]; then + printf 'Automatic TLS preflight failed: %s is required.\n' "$variable_name" >&2 + exit 1 + fi + + case $port in + *[!0-9]*) + printf 'Automatic TLS preflight failed: %s must be a numeric port from 1 through 65535.\n' "$variable_name" >&2 + exit 1 + ;; + esac + + if ! awk -v port="$port" 'BEGIN { exit !(port >= 1 && port <= 65535) }'; then + printf 'Automatic TLS preflight failed: %s must be a numeric port from 1 through 65535.\n' "$variable_name" >&2 + exit 1 + fi + + if awk -v port="$port" 'BEGIN { exit !(port == 80) }'; then + printf 'Automatic TLS preflight failed: %s cannot use port 80; Wings reserves it for the ACME HTTP-01 challenge.\n' "$variable_name" >&2 + exit 1 + fi +} + +validate_port WINGS_API_PORT "$api_port" +validate_port WINGS_SFTP_PORT "$sftp_port" + +if awk -v api="$api_port" -v sftp="$sftp_port" 'BEGIN { exit !(api == sftp) }'; then + printf '%s\n' 'Automatic TLS preflight failed: WINGS_API_PORT and WINGS_SFTP_PORT must be different.' >&2 + exit 1 +fi + +printf 'Automatic TLS port preflight passed (API %s, SFTP %s).\n' "$api_port" "$sftp_port" + +if [ "$check_only" = true ]; then + exit 0 +fi + +exec docker compose --project-directory "$script_dir" -f "$compose_file" "$@" diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 4abae3c0..3ad5af6a 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -23,7 +23,8 @@ services: - "/etc/ssl/certs:/etc/ssl/certs:ro" # you may need /srv/daemon-data if you are upgrading from an old daemon #- "/srv/daemon-data/:/srv/daemon-data/" - # Required for ssl if you use let's encrypt. uncomment to use. + # Required only when Wings uses certificate files generated by Certbot on the host. + # For Wings-managed Let's Encrypt certificates, use docker-compose.auto-tls.example.yml. #- "/etc/letsencrypt/:/etc/letsencrypt/" networks: diff --git a/scripts/test-docker-compose-auto-tls.sh b/scripts/test-docker-compose-auto-tls.sh new file mode 100755 index 00000000..229deb7b --- /dev/null +++ b/scripts/test-docker-compose-auto-tls.sh @@ -0,0 +1,84 @@ +#!/bin/sh + +set -eu + +repository_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +wrapper="$repository_root/docker-compose.auto-tls.sh" +empty_env=$(mktemp) +configured_env=$(mktemp) +project_directory=$(mktemp -d) +working_directory=$(mktemp -d) +trap 'rm -f "$empty_env" "$configured_env"; rm -rf "$project_directory" "$working_directory"' EXIT HUP INT TERM + +printf '%s\n' \ + 'WINGS_API_PORT="8443"' \ + 'WINGS_SFTP_PORT=2022 # default SFTP port' \ + > "$configured_env" + +cp "$wrapper" "$project_directory/docker-compose.auto-tls.sh" +cp "$repository_root/docker-compose.auto-tls.example.yml" \ + "$project_directory/docker-compose.auto-tls.example.yml" +# Preserve these expressions literally so Docker Compose resolves them. +# shellcheck disable=SC2016 +printf '%s\n' \ + 'DEFAULT_API_PORT=8443' \ + 'WINGS_API_PORT=${DEFAULT_API_PORT:-443}' \ + 'WINGS_SFTP_PORT=${DEFAULT_SFTP_PORT:-2022}' \ + > "$project_directory/.env" + +expect_success() { + description=$1 + shift + + if ! output=$("$@" 2>&1); then + printf 'FAIL: %s\n%s\n' "$description" "$output" >&2 + exit 1 + fi +} + +expect_failure() { + description=$1 + expected=$2 + shift 2 + + if output=$("$@" 2>&1); then + printf 'FAIL: %s unexpectedly passed.\n' "$description" >&2 + exit 1 + fi + + if ! printf '%s\n' "$output" | grep -F "$expected" >/dev/null; then + printf 'FAIL: %s returned an unexpected error.\n%s\n' "$description" "$output" >&2 + exit 1 + fi +} + +expect_failure 'unset API port' 'WINGS_API_PORT is required' \ + env -u WINGS_API_PORT -u WINGS_SFTP_PORT sh "$wrapper" --env-file "$empty_env" --check +expect_failure 'non-numeric API port' 'WINGS_API_PORT must be a numeric port from 1 through 65535' \ + env WINGS_API_PORT=invalid WINGS_SFTP_PORT=2022 sh "$wrapper" --check +expect_failure 'port below the lower boundary' 'WINGS_API_PORT must be a numeric port from 1 through 65535' \ + env WINGS_API_PORT=0 WINGS_SFTP_PORT=2022 sh "$wrapper" --check +expect_failure 'port above the upper boundary' 'WINGS_API_PORT must be a numeric port from 1 through 65535' \ + env WINGS_API_PORT=65536 WINGS_SFTP_PORT=2022 sh "$wrapper" --check +expect_failure 'reserved ACME port' 'WINGS_API_PORT cannot use port 80' \ + env WINGS_API_PORT=80 WINGS_SFTP_PORT=2022 sh "$wrapper" --check +expect_failure 'duplicate service ports' 'WINGS_API_PORT and WINGS_SFTP_PORT must be different' \ + env WINGS_API_PORT=2022 WINGS_SFTP_PORT=2022 sh "$wrapper" --check +expect_failure 'multiple environment files' 'only one --env-file is supported' \ + env WINGS_API_PORT=8443 WINGS_SFTP_PORT=2022 sh "$wrapper" \ + --env-file "$empty_env" --env-file "$configured_env" --check + +expect_success 'lower and upper boundaries' \ + env WINGS_API_PORT=1 WINGS_SFTP_PORT=65535 sh "$wrapper" --check +expect_success 'typical API and SFTP ports' \ + env WINGS_API_PORT=8443 WINGS_SFTP_PORT=2022 sh "$wrapper" --check +expect_success 'ports loaded from a Compose environment file' \ + env -u WINGS_API_PORT -u WINGS_SFTP_PORT sh "$wrapper" --env-file "$configured_env" --check +# Positional parameters are intentionally expanded by the child shell. +# shellcheck disable=SC2016 +expect_success 'project environment interpolation from another working directory' \ + env -u WINGS_API_PORT -u WINGS_SFTP_PORT sh -c \ + 'cd "$1" && sh "$2/docker-compose.auto-tls.sh" --check' \ + test "$working_directory" "$project_directory" + +printf '%s\n' 'Automatic TLS Compose preflight tests passed.'