From e36e333a860c59c0c74ce0f90137a128ce7880d8 Mon Sep 17 00:00:00 2001 From: Angel Knutsen Aune Date: Tue, 18 Aug 2026 19:15:05 +0200 Subject: [PATCH 1/3] docs: add optional automatic TLS compose example --- Dockerfile | 2 +- docker-compose.auto-tls.example.yml | 67 +++++++++++++++++++++++++++++ docker-compose.example.yml | 3 +- 3 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 docker-compose.auto-tls.example.yml 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..df73fd74 --- /dev/null +++ b/docker-compose.auto-tls.example.yml @@ -0,0 +1,67 @@ +# 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 +# +# 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.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: From a4a00033049002858262f7cbb1f28eca6f77bcdb Mon Sep 17 00:00:00 2001 From: Angel Knutsen Aune Date: Tue, 18 Aug 2026 20:21:27 +0200 Subject: [PATCH 2/3] fix: validate automatic TLS service ports --- .github/workflows/push.yaml | 13 +++ docker-compose.auto-tls.example.yml | 5 + docker-compose.auto-tls.sh | 148 ++++++++++++++++++++++++ scripts/test-docker-compose-auto-tls.sh | 62 ++++++++++ 4 files changed, 228 insertions(+) create mode 100755 docker-compose.auto-tls.sh create mode 100755 scripts/test-docker-compose-auto-tls.sh diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index bf19d312..c85665bc 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -9,6 +9,19 @@ on: - main jobs: + test-auto-tls-compose: + name: Test automatic TLS Compose preflight + runs-on: ubuntu-22.04 + + 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/docker-compose.auto-tls.example.yml b/docker-compose.auto-tls.example.yml index df73fd74..57a79d62 100644 --- a/docker-compose.auto-tls.example.yml +++ b/docker-compose.auto-tls.example.yml @@ -19,6 +19,11 @@ # 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. diff --git a/docker-compose.auto-tls.sh b/docker-compose.auto-tls.sh new file mode 100755 index 00000000..528e4405 --- /dev/null +++ b/docker-compose.auto-tls.sh @@ -0,0 +1,148 @@ +#!/bin/sh + +set -eu + +script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +compose_file="$script_dir/docker-compose.auto-tls.example.yml" +env_file="${PWD}/.env" +check_only=false +expect_env_file=false + +for argument do + if [ "$expect_env_file" = true ]; then + env_file=$argument + expect_env_file=false + continue + fi + + case $argument in + --env-file) + expect_env_file=true + ;; + --env-file=*) + env_file=${argument#--env-file=} + ;; + --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 + +read_env_value() { + variable_name=$1 + + if [ ! -f "$env_file" ]; then + return + fi + + awk -v wanted="$variable_name" ' + function trim(value) { + sub(/^[[:space:]]+/, "", value) + sub(/[[:space:]]+$/, "", value) + return value + } + + { + line = $0 + sub(/\r$/, "", line) + line = trim(line) + + if (line == "" || substr(line, 1, 1) == "#") { + next + } + + sub(/^export[[:space:]]+/, "", line) + separator = index(line, "=") + + if (separator == 0) { + next + } + + key = trim(substr(line, 1, separator - 1)) + + if (key != wanted) { + next + } + + value = trim(substr(line, separator + 1)) + + if (length(value) >= 2 && + ((substr(value, 1, 1) == "\"" && substr(value, length(value), 1) == "\"") || + (substr(value, 1, 1) == "\047" && substr(value, length(value), 1) == "\047"))) { + value = substr(value, 2, length(value) - 2) + } else { + sub(/[[:space:]]+#.*/, "", value) + value = trim(value) + } + + result = value + found = 1 + } + + END { + if (found) { + print result + } + } + ' "$env_file" +} + +if [ "${WINGS_API_PORT+x}" = x ]; then + api_port=$WINGS_API_PORT +else + api_port=$(read_env_value WINGS_API_PORT) +fi + +if [ "${WINGS_SFTP_PORT+x}" = x ]; then + sftp_port=$WINGS_SFTP_PORT +else + sftp_port=$(read_env_value WINGS_SFTP_PORT) +fi + +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 -f "$compose_file" "$@" diff --git a/scripts/test-docker-compose-auto-tls.sh b/scripts/test-docker-compose-auto-tls.sh new file mode 100755 index 00000000..273a1c58 --- /dev/null +++ b/scripts/test-docker-compose-auto-tls.sh @@ -0,0 +1,62 @@ +#!/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) +trap 'rm -f "$empty_env" "$configured_env"' EXIT HUP INT TERM + +printf '%s\n' \ + 'WINGS_API_PORT="8443"' \ + 'WINGS_SFTP_PORT=2022 # default SFTP port' \ + > "$configured_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_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 + +printf '%s\n' 'Automatic TLS Compose preflight tests passed.' From c33b7e286523fa511629317d4fb7a2f653fed97f Mon Sep 17 00:00:00 2001 From: Angel Knutsen Aune Date: Tue, 18 Aug 2026 20:34:17 +0200 Subject: [PATCH 3/3] fix: align TLS preflight with Compose environment --- .github/workflows/push.yaml | 2 + docker-compose.auto-tls.sh | 114 ++++++++++-------------- scripts/test-docker-compose-auto-tls.sh | 24 ++++- 3 files changed, 74 insertions(+), 66 deletions(-) diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index c85665bc..e9abaf67 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -12,6 +12,8 @@ jobs: test-auto-tls-compose: name: Test automatic TLS Compose preflight runs-on: ubuntu-22.04 + permissions: + contents: read steps: - name: Code checkout diff --git a/docker-compose.auto-tls.sh b/docker-compose.auto-tls.sh index 528e4405..40784894 100755 --- a/docker-compose.auto-tls.sh +++ b/docker-compose.auto-tls.sh @@ -4,13 +4,15 @@ set -eu script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) compose_file="$script_dir/docker-compose.auto-tls.example.yml" -env_file="${PWD}/.env" +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 @@ -21,6 +23,7 @@ for argument do ;; --env-file=*) env_file=${argument#--env-file=} + env_file_count=$((env_file_count + 1)) ;; --check) check_only=true @@ -33,76 +36,57 @@ if [ "$expect_env_file" = true ]; then exit 1 fi -read_env_value() { - variable_name=$1 - - if [ ! -f "$env_file" ]; then - return - fi - - awk -v wanted="$variable_name" ' - function trim(value) { - sub(/^[[:space:]]+/, "", value) - sub(/[[:space:]]+$/, "", value) - return value - } - - { - line = $0 - sub(/\r$/, "", line) - line = trim(line) - - if (line == "" || substr(line, 1, 1) == "#") { - next - } - - sub(/^export[[:space:]]+/, "", line) - separator = index(line, "=") - - if (separator == 0) { - next - } - - key = trim(substr(line, 1, separator - 1)) - - if (key != wanted) { - next - } +if [ "$env_file_count" -gt 1 ]; then + printf '%s\n' 'Automatic TLS preflight failed: only one --env-file is supported.' >&2 + exit 1 +fi - value = trim(substr(line, separator + 1)) +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 - if (length(value) >= 2 && - ((substr(value, 1, 1) == "\"" && substr(value, length(value), 1) == "\"") || - (substr(value, 1, 1) == "\047" && substr(value, length(value), 1) == "\047"))) { - value = substr(value, 2, length(value) - 2) - } else { - sub(/[[:space:]]+#.*/, "", value) - value = trim(value) - } +# 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 - result = value - found = 1 - } +read_resolved_value() { + variable_name=$1 - END { - if (found) { - print result - } + printf '%s\n' "$compose_environment" | awk -v wanted="$variable_name" ' + index($0, wanted "=") == 1 { + print substr($0, length(wanted) + 2) + exit } - ' "$env_file" + ' } -if [ "${WINGS_API_PORT+x}" = x ]; then - api_port=$WINGS_API_PORT -else - api_port=$(read_env_value WINGS_API_PORT) -fi - -if [ "${WINGS_SFTP_PORT+x}" = x ]; then - sftp_port=$WINGS_SFTP_PORT -else - sftp_port=$(read_env_value WINGS_SFTP_PORT) -fi +api_port=$(read_resolved_value WINGS_API_PORT) +sftp_port=$(read_resolved_value WINGS_SFTP_PORT) validate_port() { variable_name=$1 @@ -145,4 +129,4 @@ if [ "$check_only" = true ]; then exit 0 fi -exec docker compose -f "$compose_file" "$@" +exec docker compose --project-directory "$script_dir" -f "$compose_file" "$@" diff --git a/scripts/test-docker-compose-auto-tls.sh b/scripts/test-docker-compose-auto-tls.sh index 273a1c58..229deb7b 100755 --- a/scripts/test-docker-compose-auto-tls.sh +++ b/scripts/test-docker-compose-auto-tls.sh @@ -6,13 +6,26 @@ repository_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) wrapper="$repository_root/docker-compose.auto-tls.sh" empty_env=$(mktemp) configured_env=$(mktemp) -trap 'rm -f "$empty_env" "$configured_env"' EXIT HUP INT TERM +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 @@ -51,6 +64,9 @@ 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 @@ -58,5 +74,11 @@ 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.'