HTTPS ueber die blanke IP: Standard-Server statt abgelehntem Handshake

https://<IP>/cmk/ endete im Browser mit ERR_SSL_UNRECOGNIZED_NAME_ALERT,
waehrend dieselbe Adresse ueber http funktionierte. Die Ursache liegt
nicht beim Zertifikat: RFC 6066 laesst in der SNI-Erweiterung keine
IP-Adressen zu, der Browser schickt bei einer IP also gar keinen Namen.
nginx kann den Server-Block dann nicht ueber den Namen waehlen und
faellt auf den Standard-Server zurueck - bei NPM ist das der Block aus
/etc/nginx/conf.d/default.conf mit "ssl_reject_handshake on". Ueber HTTP
faellt das nicht auf, dort entscheidet der Host-Kopf, und der traegt die
IP.

Nachgeprueft am Image jc21/nginx-proxy-manager:2 (2.15.1): der Block
steht dort in Zeile 36, und nginx.conf liest conf.d/*.conf vor den
Proxy-Hosts ein - damit wird er zum Standard-Server, obwohl keiner der
Bloecke "default_server" gesetzt hat. Mit einem eigenen Block, der das
Kennzeichen ausdruecklich traegt, ist der Fall entschieden; Aufrufe mit
Namen gehen unveraendert an ihren Proxy-Host.

Neu ist "proxy default":

  proxy default                 Stand anzeigen
  proxy default --domain X      Proxy-Host X spiegeln
  proxy default --off           Spiegelung entfernen
  --restart                     Proxy Manager gleich neu starten

Gespiegelt wird ueber die API: Ziel, Zertifikat und die zusaetzlichen
Pfade des Proxy-Hosts landen in einem Standard-Server-Block in der
Zusatzkonfiguration. Fuehrt das Zertifikat die IP nicht als
Alternativnamen, sagt der Befehl das vorher - bei Lets Encrypt geht es
prinzipbedingt nicht.

Dabei aufgefallen: die Zusatzkonfiguration liegt im Datenverzeichnis des
Proxy Managers und gehoert dem Container. Ohne Schreibrecht scheiterte
bisher nur ein nacktes "touch" - jetzt sagt npm_conf_writable, woran es
liegt, und nennt den sudo-Aufruf. Das gilt auch fuer "npm-port".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-20 20:17:10 +02:00
co-authored by Claude Opus 5
parent 989256e082
commit 8782c6d5c7
3 changed files with 458 additions and 7 deletions
+329 -7
View File
@@ -2753,25 +2753,54 @@ NPM_CUSTOM_CONF_REL="nginx/custom/http.conf"
NPM_BLOCK_START="# >>> cmk-manage.sh: Admin-Port - nicht von Hand aendern >>>"
NPM_BLOCK_END="# <<< cmk-manage.sh: Admin-Port <<<"
npm_custom_conf_path() {
local base="${NPM_DATA_DIR:-$PROJECT_DIR/data/npm/data}"
case "$base" in /*) : ;; *) base="$PROJECT_DIR/${base#./}" ;; esac
printf '%s/%s\n' "$base" "$NPM_CUSTOM_CONF_REL"
# Die Zusatzkonfiguration liegt im Datenverzeichnis des Proxy Managers,
# und das gehoert dem Container - also root. Ohne Schreibrecht scheitert
# sonst erst "touch" mit einer nackten Fehlermeldung.
npm_conf_writable() { # datei
local f="$1" d; d="$(dirname "$f")"
if [ -e "$f" ]; then
[ -w "$f" ] && return 0
elif [ -d "$d" ]; then
[ -w "$d" ] && return 0
else
mkdir -p "$d" 2>/dev/null && return 0
fi
err "Keine Schreibrechte auf $f"
printf ' Das Verzeichnis gehoert dem Proxy-Manager-Container (root).
Bitte mit sudo aufrufen:
sudo %s %s\n' "$SELF" "${CMD_LINE:-<befehl>}" >&2
return 1
}
npm_data_dir_abs() {
local base="${NPM_DATA_DIR:-$PROJECT_DIR/data/npm/data}"
case "$base" in /*) : ;; *) base="$PROJECT_DIR/${base#./}" ;; esac
printf '%s\n' "$base"
}
npm_custom_conf_path() {
printf '%s/%s\n' "$(npm_data_dir_abs)" "$NPM_CUSTOM_CONF_REL"
}
NPM_DEF_START="# >>> cmk-manage.sh: HTTPS ohne SNI - nicht von Hand aendern >>>"
NPM_DEF_END="# <<< cmk-manage.sh: HTTPS ohne SNI <<<"
# Vorhandenen verwalteten Block entfernen, Fremdinhalte bleiben erhalten
npm_conf_strip_block() {
local f="$1"
npm_conf_strip_marked() {
local f="$1" st="$2" en="$3"
[ -f "$f" ] || return 0
awk -v s="$NPM_BLOCK_START" -v e="$NPM_BLOCK_END" '
awk -v s="$st" -v e="$en" '
$0 == s { skip=1; next }
$0 == e { skip=0; next }
!skip { print }
' "$f" > "$f.tmp" && mv "$f.tmp" "$f"
}
npm_conf_strip_block() { npm_conf_strip_marked "$1" "$NPM_BLOCK_START" "$NPM_BLOCK_END"; }
npm_conf_write_block() {
local f="$1" port="$2"
npm_conf_writable "$f" || return 1
mkdir -p "$(dirname "$f")"
touch "$f"
npm_conf_strip_block "$f"
@@ -3555,6 +3584,144 @@ proxy_location_json() { # spec -> JSON-Objekt auf stdout
}
# --- Proxy-Hosts ---------------------------------------------------------
# =====================================================================
# HTTPS ueber die blanke IP
#
# Ruft man den Server mit https://<IP>/ auf, sendet der Browser keinen
# SNI-Namen - RFC 6066 laesst dort keine IP-Adressen zu. nginx kann den
# passenden Server-Block dann nicht ueber den Namen auswaehlen und faellt
# auf den Standard-Server zurueck. Bei NPM ist das der Block aus
# /etc/nginx/conf.d/default.conf, und der antwortet mit
# "ssl_reject_handshake on" - im Browser als
# ERR_SSL_UNRECOGNIZED_NAME_ALERT.
#
# Ueber HTTP faellt das nicht auf: dort waehlt nginx den Server-Block
# anhand des Host-Kopfes aus, und der traegt die IP.
#
# Abhilfe ist ein eigener Block mit "default_server" in der
# Zusatzkonfiguration, die nginx nach den Proxy-Hosts einliest. Keiner
# der NPM-Bloecke setzt das Kennzeichen selbst, deshalb gibt es keinen
# Streit darum. Benannte Aufrufe gehen weiterhin an ihren Proxy-Host -
# der Standard-Server greift nur, wenn kein Name passt.
# =====================================================================
# Zertifikatspfade IM CONTAINER zu einer NPM-Zertifikats-ID
npm_cert_paths() { # id -> "fullchain|privkey|anbieter"
local cid="$1" o prov
o="$(npm_api GET "/api/nginx/certificates/$cid")" || return 1
prov="$(json_str "$o" provider)"
if [ "$prov" = letsencrypt ]; then
printf '/etc/letsencrypt/live/npm-%s/fullchain.pem|/etc/letsencrypt/live/npm-%s/privkey.pem|%s\n' \
"$cid" "$cid" "$prov"
else
printf '/data/custom_ssl/npm-%s/fullchain.pem|/data/custom_ssl/npm-%s/privkey.pem|%s\n' \
"$cid" "$cid" "${prov:-other}"
fi
}
# Deckt das Zertifikat die IP ab? Nur pruefbar, wenn die Datei hier liegt.
npm_cert_covers_ip() { # zertifikats-id ip
local cid="$1" ip="$2" f
f="$(npm_data_dir_abs)/custom_ssl/npm-$cid/fullchain.pem"
[ -r "$f" ] || return 0 # nicht pruefbar - dann auch nicht meckern
( set +o pipefail; openssl x509 -in "$f" -noout -text 2>/dev/null | grep -q "IP Address:$ip" )
}
# Einen Proxy-Host in einen Standard-Server-Block spiegeln
npm_default_write() { # conf objekt-json fullchain privkey domain
local f="$1" o="$2" full="$3" key="$4" dom="$5"
npm_conf_writable "$f" || return 1
local sch host port seg lp lsch lh lpo
sch="$(json_str "$o" forward_scheme)"; [ -n "$sch" ] || sch=http
host="$(json_str "$o" forward_host)"
port="$(json_num "$o" forward_port)"
mkdir -p "$(dirname "$f")"
touch "$f"
npm_conf_strip_marked "$f" "$NPM_DEF_START" "$NPM_DEF_END"
{
printf '%s\n' "$NPM_DEF_START"
cat <<EOF
# Standard-Server fuer HTTPS: greift, wenn der Browser keinen Namen
# mitschickt - das ist beim Aufruf ueber die blanke IP der Fall.
# Spiegelt den Proxy-Host "$dom".
# Erzeugt von scripts/cmk-manage.sh proxy default --domain $dom
server {
listen 443 ssl default_server;
EOF
if [ "${NPM_DISABLE_IPV6:-false}" != true ]; then
printf ' listen [::]:443 ssl default_server;\n'
fi
cat <<EOF
server_name _;
ssl_certificate $full;
ssl_certificate_key $key;
include conf.d/include/ssl-ciphers.conf;
access_log /data/logs/default_ssl_access.log standard;
error_log /data/logs/default_ssl_error.log warn;
client_max_body_size 0;
proxy_read_timeout 300s;
EOF
# Zusaetzliche Pfade zuerst - laengster Treffer gewinnt bei nginx
# ohnehin, aber so liest es sich wie im Proxy Manager.
while IFS= read -r seg; do
case "$seg" in *'"path"'*) : ;; *) continue ;; esac
lp="$(printf '%s' "$seg" | sed -n 's/.*"path":"\([^"]*\)".*/\1/p')"
lsch="$(printf '%s' "$seg" | sed -n 's/.*"forward_scheme":"\([^"]*\)".*/\1/p')"
lh="$(printf '%s' "$seg" | sed -n 's/.*"forward_host":"\([^"]*\)".*/\1/p')"
lpo="$(printf '%s' "$seg" | sed -n 's/.*"forward_port":\([0-9]*\).*/\1/p')"
[ -n "$lp" ] && [ -n "$lh" ] && [ -n "$lpo" ] || continue
[ -n "$lsch" ] || lsch=http
cat <<EOF
location $lp {
proxy_pass $lsch://$lh:$lpo;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
}
EOF
done < <(printf '%s' "$o" | tr '}' '\n')
cat <<EOF
location / {
proxy_pass $sch://$host:$port;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
}
}
EOF
printf '%s\n' "$NPM_DEF_END"
} >> "$f"
sed -i '/./,$!d' "$f"
}
npm_reload_hint() { # restart-flag
if [ -n "${1:-}" ] && container_running "$NPM_CONTAINER_NAME"; then
log "Starte '$NPM_CONTAINER_NAME' neu, damit nginx die Konfiguration liest ..."
if $RUNTIME restart "$NPM_CONTAINER_NAME" >/dev/null; then
ok "Proxy Manager neu gestartet."
else
warn "Neustart fehlgeschlagen - bitte manuell: $SELF restart"
fi
else
log "Wirksam nach: $SELF restart (bzw. Neustart des Proxy Managers)"
fi
}
cmd_proxy() {
local sub="${1:-list}"; [ $# -gt 0 ] && shift || true
@@ -3639,6 +3806,116 @@ cmd_proxy() {
echo
return 0 ;;
default|ip)
local dom="" off="" restart="" conf
while [ $# -gt 0 ]; do
case "$1" in
--domain) dom="$2"; shift 2 ;;
--off|--aus) off=1; shift ;;
--restart) restart=1; shift ;;
--help|-h)
cat <<EOF
HTTPS ueber die blanke IP ermoeglichen.
$SELF proxy default Stand anzeigen
$SELF proxy default --domain NAME Proxy-Host spiegeln
$SELF proxy default --off Spiegelung entfernen
Ruft man den Server als https://<IP>/ auf, schickt der Browser keinen
Namen mit (SNI kennt keine IP-Adressen). nginx kann den Proxy-Host dann
nicht ueber den Namen finden und faellt auf seinen Standard-Server
zurueck - der lehnt den Handshake ab: ERR_SSL_UNRECOGNIZED_NAME_ALERT.
Ueber HTTP faellt das nicht auf, dort entscheidet der Host-Kopf.
Dieser Befehl legt einen Standard-Server an, der denselben Weg nimmt wie
der genannte Proxy-Host - samt der zusaetzlichen Pfade. Aufrufe mit
Namen gehen weiterhin an ihren eigenen Proxy-Host.
EOF
return 0 ;;
*) dom="$1"; shift ;;
esac
done
conf="$(npm_custom_conf_path)"
if [ -n "$off" ]; then
if ! grep -qsF "$NPM_DEF_START" "$conf"; then
ok "Es ist kein Standard-Server eingetragen."
return 0
fi
npm_conf_strip_marked "$conf" "$NPM_DEF_START" "$NPM_DEF_END"
[ -s "$conf" ] || rm -f "$conf"
ok "Standard-Server entfernt: $conf"
npm_reload_hint "$restart"
return 0
fi
if [ -z "$dom" ]; then
printf '\n%sHTTPS ueber die blanke IP%s\n' "$C_BLD" "$C_RST"
if grep -qsF "$NPM_DEF_START" "$conf"; then
test_result ok "eingerichtet"
printf ' Datei : %s\n' "$conf"
printf ' Spiegelt: %s\n' \
"$(sed -n 's/^# Spiegelt den Proxy-Host "\(.*\)"\.$/\1/p' "$conf" | head -1)"
printf '\n Entfernen mit: %s proxy default --off\n\n' "$SELF"
else
test_result warn "nicht eingerichtet"
printf ' https://<IP>/ endet dann in
ERR_SSL_UNRECOGNIZED_NAME_ALERT, http://<IP>/ geht.\n'
printf '\n Einrichten mit: %s proxy default --domain <name-des-proxy-hosts>\n' "$SELF"
printf ' Vorhandene : %s proxy list\n\n' "$SELF"
fi
return 0
fi
npm_require_running || return 1
NPM_TOKEN="$(npm_api_token)" || return 1
# Proxy-Host zum Namen suchen
local all pid="" i
all="$(npm_api GET /api/nginx/proxy-hosts)"
for i in $(json_ids "$all"); do
if ( set +o pipefail; npm_api GET "/api/nginx/proxy-hosts/$i" | grep -qF "\"$dom\"" ); then
pid="$i"; break
fi
done
[ -n "$pid" ] || die "Kein Proxy-Host fuer '$dom' gefunden - '$SELF proxy list' zeigt die vorhandenen."
local o cid
o="$(npm_api GET "/api/nginx/proxy-hosts/$pid")"
cid="$(json_num "$o" certificate_id)"
if [ -z "$cid" ] || [ "$cid" = 0 ]; then
die "Der Proxy-Host '$dom' hat kein Zertifikat.
Ohne Zertifikat gibt es nichts, was ohne SNI ausgeliefert werden koennte.
Erst eines zuweisen: $SELF proxy add --domain $dom --cert ... --update"
fi
local paths full key prov
paths="$(npm_cert_paths "$cid")" || die "Zertifikat $cid nicht abrufbar."
full="${paths%%|*}"; paths="${paths#*|}"
key="${paths%%|*}"; prov="${paths##*|}"
local ip; ip="$(primary_ip)"
if [ "$prov" = letsencrypt ]; then
warn "Das Zertifikat stammt von Lets Encrypt und lautet auf '$dom'.
Beim Aufruf ueber die IP meldet der Browser deshalb einen
Namensfehler (anklickbar, aber unschoen). Fuer den IP-Zugang ist
ein selbstsigniertes Zertifikat mit der IP als Alternativname
besser: $SELF cert --domain $dom --alt $ip"
elif [ -n "$ip" ] && ! npm_cert_covers_ip "$cid" "$ip"; then
warn "Das Zertifikat fuehrt die IP $ip nicht als Alternativnamen.
Der Browser meldet dann einen Namensfehler. Neu erzeugen mit:
$SELF cert --domain $dom --alt $ip
und dem Proxy-Host zuweisen."
fi
CMD_LINE="proxy default --domain $dom" npm_default_write "$conf" "$o" "$full" "$key" "$dom" || return 1
ok "Standard-Server geschrieben: $conf"
printf ' Spiegelt Proxy-Host %s (%s), Zertifikat %s (%s)\n' "$pid" "$dom" "$cid" "$prov"
if [ -n "$ip" ]; then printf ' Danach erreichbar unter: https://%s/\n' "$ip"; fi
npm_reload_hint "$restart"
return 0 ;;
del|delete|rm)
local pid=""
while [ $# -gt 0 ]; do
@@ -4970,6 +5247,7 @@ Ziel : ${PROXY_FORWARD_HOST:-127.0.0.1}:${PROXY_FORWARD_PORT:-5000}" \
list "Vorhandene Proxy-Hosts anzeigen" \
show "Einzelheiten eines Proxy-Hosts (mit Pfaden)" \
certs "Vorhandene Zertifikate anzeigen" \
ipzugang "HTTPS ueber die blanke IP (ohne Namen) ermoeglichen" \
del "Proxy-Host loeschen" \
status "Anmeldung pruefen" \
reset "Datenbank zuruecksetzen (Konto neu anlegen lassen)" \
@@ -4985,6 +5263,7 @@ Ziel : ${PROXY_FORWARD_HOST:-127.0.0.1}:${PROXY_FORWARD_PORT:-5000}" \
ui_text "Proxy-Host $d3" "$f3"; rm -f "$f3" ;;
certs) local f2; f2="$(mktemp)"; NO_COLOR=1 cmd_proxy certs >"$f2" 2>&1 || true
ui_text "Zertifikate" "$f2"; rm -f "$f2" ;;
ipzugang) gui_proxy_default || true ;;
del) gui_proxy_del || true ;;
status) ui_run "Anmeldung pruefen" bash -c "cd '$PROJECT_DIR' && NO_COLOR=1 '$SELF' proxy status" ;;
reset) gui_npm_reset || true ;;
@@ -4993,6 +5272,49 @@ Ziel : ${PROXY_FORWARD_HOST:-127.0.0.1}:${PROXY_FORWARD_PORT:-5000}" \
done
}
gui_proxy_default() {
local f rc=0
f="$(mktemp)"
NO_COLOR=1 cmd_proxy default >"$f" 2>&1 || true
ui_text "HTTPS ueber die blanke IP" "$f"; rm -f "$f"
local sel
sel="$(ui_menu "HTTPS ueber die blanke IP" \
"Ruft man den Server als https://<IP>/ auf, schickt der Browser keinen
Namen mit - SNI kennt keine IP-Adressen. nginx findet den Proxy-Host
dann nicht und lehnt den Handshake ab (ERR_SSL_UNRECOGNIZED_NAME_ALERT).
Ueber http://<IP>/ faellt das nicht auf, dort zaehlt der Host-Kopf.
Ein Standard-Server nimmt genau diese Aufrufe an und geht denselben Weg
wie der gewaehlte Proxy-Host, samt seiner zusaetzlichen Pfade.
Aufrufe mit Namen bleiben unberuehrt." \
ein "Einrichten - Proxy-Host spiegeln" \
aus "Entfernen" \
back "zurueck")" || return 0
case "$sel" in
back) return 0 ;;
aus) ui_run "Standard-Server entfernen" bash -c \
"cd '$PROJECT_DIR' && NO_COLOR=1 '$SELF' proxy default --off --restart" || true
return 0 ;;
esac
local dom
dom="$(ui_input "Einrichten" "Name des Proxy-Hosts, der gespiegelt werden soll:" \
"${PROXY_DOMAIN:-}")" || return 0
[ -n "$dom" ] || return 0
f="$(mktemp)"
NO_COLOR=1 cmd_proxy default --domain "$dom" --restart >"$f" 2>&1 || rc=$?
if [ "$rc" -eq 0 ]; then
ui_text "Standard-Server eingerichtet" "$f"
else
ui_text "Einrichten fehlgeschlagen" "$f"
fi
rm -f "$f"
return 0
}
gui_proxy_add() {
if ! npm_require_running 2>/dev/null; then
ui_msg "Proxy Manager laeuft nicht" \