|
#!/usr/bin/env bash |
|
# |
|
# Sync a TLS certificate (fullchain + privkey) to AdGuard Home via its control API. |
|
# Cert source: local PEM paths (LOCAL_FULLCHAIN/LOCAL_PRIVKEY) or SSH + container (CERT_SOURCE_*). |
|
# |
|
# Usage: |
|
# ./sync-adguard-cert.sh [--dry-run] |
|
# |
|
# Configure via environment or .env (see .env.example). |
|
|
|
set -euo pipefail |
|
|
|
# --- Configuration (override with env or .env) --- |
|
# Certificate source (for SSH + container fetch) |
|
CERT_SOURCE_HOST="${CERT_SOURCE_HOST:-}" |
|
CERT_SOURCE_IP="${CERT_SOURCE_IP:-}" |
|
CERT_SOURCE_SSH_USER="${CERT_SOURCE_SSH_USER:-}" |
|
CERT_SOURCE_CONTAINER="${CERT_SOURCE_CONTAINER:-}" |
|
CERT_SOURCE_DIR="${CERT_SOURCE_DIR:-/config/keys/letsencrypt}" |
|
DOMAIN="${DOMAIN:-}" |
|
|
|
ADGUARD_HOST="${ADGUARD_HOST:-adguard}" |
|
ADGUARD_IP="${ADGUARD_IP:-}" |
|
ADGUARD_PORT="${ADGUARD_PORT:-443}" |
|
ADGUARD_USER="${ADGUARD_USER:-}" |
|
ADGUARD_PASS="${ADGUARD_PASS:-}" |
|
ADGUARD_USE_HTTPS="${ADGUARD_USE_HTTPS:-true}" |
|
# Skip TLS verification when talking to AdGuard (e.g. self-signed or internal CA) |
|
ADGUARD_INSECURE="${ADGUARD_INSECURE:-true}" |
|
# Optional: pin encryption-page settings so the script never clears them. Leave unset to keep whatever AdGuard has. |
|
ADGUARD_TLS_SERVER_NAME="${ADGUARD_TLS_SERVER_NAME:-}" |
|
ADGUARD_PORT_HTTPS="${ADGUARD_PORT_HTTPS:-}" |
|
ADGUARD_FORCE_HTTPS="${ADGUARD_FORCE_HTTPS:-}" |
|
# Optional: log cleanup. If LOG_FILE and LOG_MAX_LINES are set, truncate the log to the last LOG_MAX_LINES lines at the start of each run. |
|
LOG_FILE="${LOG_FILE:-}" |
|
LOG_MAX_LINES="${LOG_MAX_LINES:-}" |
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
|
DRY_RUN=false |
|
|
|
# Load .env if present (optional) |
|
for f in "${SCRIPT_DIR}/.env" "${SCRIPT_DIR}/.env.local"; do |
|
if [[ -f "$f" ]]; then |
|
set -a |
|
# shellcheck source=/dev/null |
|
source "$f" |
|
set +a |
|
break |
|
fi |
|
done |
|
|
|
log() { echo "[$(date -Iseconds)] $*"; } |
|
err() { echo "[$(date -Iseconds)] ERROR: $*" >&2; } |
|
|
|
usage() { |
|
echo "Usage: $0 [--dry-run]" |
|
echo "Syncs TLS cert for ${DOMAIN} to AdGuard (${ADGUARD_HOST})." |
|
echo "Set CERT_SOURCE_SSH_USER (or LOCAL_FULLCHAIN/LOCAL_PRIVKEY), ADGUARD_USER, ADGUARD_PASS (or use .env)." |
|
exit 0 |
|
} |
|
|
|
for arg in "$@"; do |
|
case "$arg" in |
|
--dry-run) DRY_RUN=true ;; |
|
-h|--help) usage ;; |
|
esac |
|
done |
|
|
|
# Optional: truncate log file to last N lines at start of each run |
|
if [[ -n "${LOG_FILE}" && -n "${LOG_MAX_LINES}" && -f "${LOG_FILE}" ]]; then |
|
if [[ "${LOG_MAX_LINES}" =~ ^[0-9]+$ ]] && [[ "${LOG_MAX_LINES}" -gt 0 ]]; then |
|
tail -n "${LOG_MAX_LINES}" "${LOG_FILE}" > "${LOG_FILE}.tmp" && mv "${LOG_FILE}.tmp" "${LOG_FILE}" |
|
fi |
|
fi |
|
|
|
# Visual separator between runs when output is appended to a log file |
|
echo "" |
|
log "---------- Sync run $(date -Iseconds) ----------" |
|
|
|
# Resolve host to IP if needed (use ADGUARD_IP / CERT_SOURCE_IP for API/SSH) |
|
resolve_host() { |
|
local host="$1" |
|
local fallback_ip="$2" |
|
if [[ -n "$fallback_ip" ]]; then |
|
echo "$fallback_ip" |
|
return |
|
fi |
|
if command -v getent &>/dev/null; then |
|
getent ahosts "$host" 2>/dev/null | awk 'NR==1{print $1; exit}' || echo "$host" |
|
else |
|
echo "$host" |
|
fi |
|
} |
|
|
|
CERT_SOURCE_TARGET=$(resolve_host "$CERT_SOURCE_HOST" "$CERT_SOURCE_IP") |
|
ADGUARD_TARGET=$(resolve_host "$ADGUARD_HOST" "$ADGUARD_IP") |
|
|
|
# --- Fetch cert from your certificate source --- |
|
# Option A: Local paths (no container or SSH required). Any PEM source works (Certbot, Caddy, nginx, etc.). |
|
# Set LOCAL_FULLCHAIN and LOCAL_PRIVKEY to fullchain.pem and privkey.pem paths. |
|
# Option B: SSH to a host and run a command (e.g. docker exec) to read certs from a container. |
|
# Option C: Local directory: script tries CERT_SOURCE_DIR then Certbot-style live/DOMAIN (only if A and B unused). |
|
CERT_FULLCHAIN_PATH="${LOCAL_FULLCHAIN:-}" |
|
CERT_PRIVKEY_PATH="${LOCAL_PRIVKEY:-}" |
|
|
|
fetch_certs_via_ssh() { |
|
local ssh_spec="${CERT_SOURCE_SSH_USER:+${CERT_SOURCE_SSH_USER}@}${CERT_SOURCE_TARGET}" |
|
local fullchain_path="/config/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" |
|
local privkey_path="/config/etc/letsencrypt/live/${DOMAIN}/privkey.pem" |
|
local fallback_full="/config/keys/letsencrypt/fullchain.pem" |
|
local fallback_priv="/config/keys/letsencrypt/privkey.pem" |
|
if [[ -n "${CERT_SOURCE_DIR}" && "${CERT_SOURCE_DIR}" != "/config/keys/letsencrypt" ]]; then |
|
fullchain_path="${CERT_SOURCE_DIR}/fullchain.pem" |
|
privkey_path="${CERT_SOURCE_DIR}/privkey.pem" |
|
fi |
|
fullchain=$(ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${ssh_spec}" \ |
|
"docker exec ${CERT_SOURCE_CONTAINER} cat ${fullchain_path} 2>/dev/null || docker exec ${CERT_SOURCE_CONTAINER} cat ${fallback_full}") |
|
privkey=$(ssh -o ConnectTimeout=10 "${ssh_spec}" \ |
|
"docker exec ${CERT_SOURCE_CONTAINER} cat ${privkey_path} 2>/dev/null || docker exec ${CERT_SOURCE_CONTAINER} cat ${fallback_priv}") |
|
echo "$fullchain" && echo "---KEY---" && echo "$privkey" |
|
} |
|
|
|
fetch_certs_local() { |
|
local dir="${CERT_SOURCE_DIR}" |
|
if [[ ! -f "${dir}/fullchain.pem" ]]; then |
|
dir="/config/etc/letsencrypt/live/${DOMAIN}" |
|
fi |
|
if [[ ! -f "${dir}/fullchain.pem" ]]; then |
|
err "Local cert not found in ${CERT_SOURCE_DIR} or ${dir}" |
|
return 1 |
|
fi |
|
cat "${dir}/fullchain.pem" && echo "---KEY---" && cat "${dir}/privkey.pem" |
|
} |
|
|
|
if [[ -n "${CERT_FULLCHAIN_PATH}" && -n "${CERT_PRIVKEY_PATH}" ]]; then |
|
if [[ ! -f "${CERT_FULLCHAIN_PATH}" || ! -f "${CERT_PRIVKEY_PATH}" ]]; then |
|
err "LOCAL_FULLCHAIN/LOCAL_PRIVKEY set but files missing." |
|
exit 1 |
|
fi |
|
FULLCHAIN_PEM=$(cat "${CERT_FULLCHAIN_PATH}") |
|
PRIVKEY_PEM=$(cat "${CERT_PRIVKEY_PATH}") |
|
log "Using local cert files: ${CERT_FULLCHAIN_PATH}, ${CERT_PRIVKEY_PATH}" |
|
elif [[ -n "${CERT_SOURCE_SSH_USER}" ]]; then |
|
log "Fetching cert from ${CERT_SOURCE_HOST} (${CERT_SOURCE_TARGET}) via SSH..." |
|
out=$(fetch_certs_via_ssh) |
|
FULLCHAIN_PEM=$(echo "$out" | sed -n '1,/---KEY---/p' | sed '/---KEY---/d') |
|
PRIVKEY_PEM=$(echo "$out" | sed -n '/---KEY---/,$p' | sed '1d') |
|
else |
|
# Try local directory (CERT_SOURCE_DIR or Certbot-style path) |
|
log "Fetching cert from local cert paths..." |
|
out=$(fetch_certs_local) |
|
FULLCHAIN_PEM=$(echo "$out" | sed -n '1,/---KEY---/p' | sed '/---KEY---/d') |
|
PRIVKEY_PEM=$(echo "$out" | sed -n '/---KEY---/,$p' | sed '1d') |
|
fi |
|
|
|
if [[ -z "${FULLCHAIN_PEM}" || -z "${PRIVKEY_PEM}" ]]; then |
|
err "Could not read certificate or private key." |
|
exit 1 |
|
fi |
|
|
|
# --- AdGuard API: get current TLS config, then set with new cert --- |
|
ADGUARD_PROTO="${ADGUARD_USE_HTTPS,,}" |
|
[[ "$ADGUARD_PROTO" = "true" ]] && ADGUARD_PROTO="https" || ADGUARD_PROTO="http" |
|
ADGUARD_BASE="${ADGUARD_PROTO}://${ADGUARD_TARGET}:${ADGUARD_PORT}" |
|
CURL_OPTS=(-s -S --connect-timeout 10 --max-time 30) |
|
[[ "${ADGUARD_INSECURE}" = "true" ]] && CURL_OPTS+=(--insecure) |
|
[[ -n "${ADGUARD_USER}" ]] && CURL_OPTS+=(-u "${ADGUARD_USER}:${ADGUARD_PASS}") |
|
|
|
get_tls_status() { |
|
curl "${CURL_OPTS[@]}" "${ADGUARD_BASE}/control/tls/status" |
|
} |
|
|
|
post_tls_configure() { |
|
local body="$1" |
|
curl "${CURL_OPTS[@]}" -X POST -H "Content-Type: application/json" -d "$body" "${ADGUARD_BASE}/control/tls/configure" |
|
} |
|
|
|
# Build JSON: AdGuard expects certificate_chain and private_key base64-encoded (decodes on receipt). |
|
# We need to preserve other TLS settings (enable_https, server_name, port_https, etc.) from current config. |
|
log "Fetching current AdGuard TLS config from ${ADGUARD_BASE}..." |
|
CURRENT=$(get_tls_status) || true |
|
if [[ -z "${CURRENT}" ]]; then |
|
err "Could not reach AdGuard at ${ADGUARD_BASE}. Check host, port, and credentials." |
|
exit 1 |
|
fi |
|
|
|
# Base64-encode PEM for API (AdGuard returns "failed to base64-decode" if given raw PEM) |
|
CHAIN_B64=$(echo "$FULLCHAIN_PEM" | base64 -w 0 2>/dev/null || echo "$FULLCHAIN_PEM" | base64 | tr -d '\n') |
|
KEY_B64=$(echo "$PRIVKEY_PEM" | base64 -w 0 2>/dev/null || echo "$PRIVKEY_PEM" | base64 | tr -d '\n') |
|
|
|
# Build new config: start from current JSON, overlay cert + key + enable_https, and optionally pin TLS settings from env. |
|
# Only certificate_chain, private_key, and enable_https are always overwritten; other fields come from GET (which can be minimal). |
|
# Set ADGUARD_TLS_SERVER_NAME / ADGUARD_PORT_HTTPS / ADGUARD_FORCE_HTTPS in .env to pin those values so they are never lost. |
|
if command -v jq &>/dev/null; then |
|
NEW_CONFIG=$(echo "$CURRENT" | jq --arg chain "$CHAIN_B64" --arg key "$KEY_B64" \ |
|
--arg sn "${ADGUARD_TLS_SERVER_NAME}" --arg port_ht "${ADGUARD_PORT_HTTPS}" --arg force_ht "${ADGUARD_FORCE_HTTPS}" ' |
|
. + {certificate_chain: $chain, private_key: $key, enable_https: true} |
|
| if $sn != "" then . + {server_name: $sn} else . end |
|
| if $port_ht != "" then . + {port_https: ($port_ht | tonumber)} else . end |
|
| if $force_ht == "true" then . + {force_https: true} elif $force_ht == "false" then . + {force_https: false} else . end') |
|
else |
|
# Minimal JSON: base64 strings + explicitly enable HTTPS (no env overrides without jq) |
|
chain_escaped=$(echo "$CHAIN_B64" | sed 's/\\/\\\\/g; s/"/\\"/g') |
|
key_escaped=$(echo "$KEY_B64" | sed 's/\\/\\\\/g; s/"/\\"/g') |
|
NEW_CONFIG="{\"certificate_chain\":\"${chain_escaped}\",\"private_key\":\"${key_escaped}\",\"enable_https\":true}" |
|
log "jq not found; consider installing jq so current TLS settings are preserved." |
|
fi |
|
|
|
if [[ "$DRY_RUN" = true ]]; then |
|
log "Dry run: would POST new TLS config to AdGuard (cert length ${#FULLCHAIN_PEM}, key length ${#PRIVKEY_PEM})." |
|
exit 0 |
|
fi |
|
|
|
log "Applying new TLS certificate to AdGuard..." |
|
RESPONSE=$(post_tls_configure "$NEW_CONFIG") || true |
|
if echo "$RESPONSE" | grep -q '"certificate_chain"'; then |
|
log "AdGuard TLS config updated successfully." |
|
else |
|
err "AdGuard may have rejected the config. Response: ${RESPONSE}" |
|
exit 1 |
|
fi |