System > HTTPS and cms:https switch the redirect lines in public/.htaccess only when the site answers over HTTPS, and take them back on a loop. HSTS is a separate second switch. The redirect block moves before the front controller rules, where it never reached pages, and .well-known is no longer denied, so the origin probe works on Apache. The Git updater keeps switched-on lines like the archive update. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
260 lines
12 KiB
PHP
260 lines
12 KiB
PHP
<?php
|
||
|
||
namespace App\Ops;
|
||
|
||
/**
|
||
* Schaltet die Umleitung auf HTTPS und HSTS in public/.htaccess ein und aus (ADR 120).
|
||
*
|
||
* Die Zeilen stehen ausgeliefert auskommentiert in der Datei: Eine Installation ohne Zertifikat
|
||
* liefe mit ihnen in eine Endlosschleife, und die Anwendung selbst erzwingt kein HTTPS, weil
|
||
* PHP hinter einem Proxy oft nur http sieht. Bisher hieß das: per FTP in die .htaccess. Dieser
|
||
* Schalter macht dasselbe aus der Verwaltung, aber nur mit Probe davor und danach:
|
||
*
|
||
* 1. Antwortet diese Installation unter https://<Adresse> mit gültigem Zertifikat? Sonst nichts.
|
||
* 2. Zeilen einschalten.
|
||
* 3. Kommt https://<Adresse> weiterhin hier an? Sonst ist es eine Schleife (der Server meldet
|
||
* HTTPS nicht, kein Proxy-Kopf) - die Datei wird sofort zurückgeschrieben.
|
||
* 4. Leitet http://<Adresse> mit 301 auf https um? Wenn nicht, liest der Server die Regel nicht
|
||
* (kein mod_rewrite, AllowOverride) - die Zeilen bleiben, schaden nicht, und die Meldung sagt es.
|
||
*
|
||
* HSTS ist ein eigener Schritt: Ein Browser merkt sich den Kopf ein Jahr lang, ein Rückweg auf
|
||
* http ist dann für jeden Besucher zu, der die Seite einmal gesehen hat.
|
||
*/
|
||
class HttpsRedirect
|
||
{
|
||
public const REDIRECT_LINES = [
|
||
'RewriteCond %{HTTPS} !=on',
|
||
'RewriteCond %{HTTP:X-Forwarded-Proto} !=https',
|
||
'RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]',
|
||
];
|
||
public const HSTS_LINE = 'Header always set Strict-Transport-Security';
|
||
|
||
/** @var \Closure(string): array{status: int, location: string, error: string} */
|
||
private \Closure $httpProbe;
|
||
|
||
/** @param \Closure(string): array{status: int, location: string, error: string}|null $httpProbe */
|
||
public function __construct(private string $projectDir, private OriginCheck $originCheck, ?\Closure $httpProbe = null)
|
||
{
|
||
$this->httpProbe = $httpProbe ?? self::probeHttp(...);
|
||
}
|
||
|
||
public function file(): string
|
||
{
|
||
return $this->projectDir.'/public/.htaccess';
|
||
}
|
||
|
||
/** @return array{redirect: bool, hsts: bool, found: bool, writable: bool} */
|
||
public function state(): array
|
||
{
|
||
$content = is_file($this->file()) ? (string) file_get_contents($this->file()) : '';
|
||
|
||
return [
|
||
'redirect' => self::active($content, self::REDIRECT_LINES),
|
||
'hsts' => self::active($content, [self::HSTS_LINE]),
|
||
'found' => self::present($content, self::REDIRECT_LINES) && self::present($content, [self::HSTS_LINE]),
|
||
'writable' => is_file($this->file()) && is_writable($this->file()),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Schaltet die Umleitung ein. Gibt Erfolg und eine Meldung in einem Satz oder zwei zurück;
|
||
* bei Misserfolg ist die Datei so, wie sie vorher war.
|
||
*
|
||
* @return array{ok: bool, message: string}
|
||
*/
|
||
public function enableRedirect(string $origin): array
|
||
{
|
||
$origin = rtrim(trim($origin), '/');
|
||
if (!preg_match('#^https://[^/?\#]+$#', $origin)) {
|
||
return ['ok' => false, 'message' => 'Erst die Adresse der Website mit https:// eintragen – die Umleitung braucht ein Ziel.'];
|
||
}
|
||
if (null !== $problem = $this->originCheck->problem($origin)) {
|
||
return ['ok' => false, 'message' => $problem.' Ohne funktionierendes HTTPS bleibt die Umleitung aus.'];
|
||
}
|
||
$before = $this->read();
|
||
if (null === $before) {
|
||
return ['ok' => false, 'message' => 'public/.htaccess fehlt oder ist nicht lesbar.'];
|
||
}
|
||
if (!self::present($before, self::REDIRECT_LINES)) {
|
||
return ['ok' => false, 'message' => 'Die Zeilen für HTTPS stehen nicht in public/.htaccess (geändert oder sehr alte Fassung). Das nächste Update bringt sie zurück.'];
|
||
}
|
||
if (self::active($before, self::REDIRECT_LINES)) {
|
||
return ['ok' => true, 'message' => 'Die Umleitung auf HTTPS ist schon eingeschaltet.'];
|
||
}
|
||
if (!$this->write(self::switchLines($before, self::REDIRECT_LINES, true))) {
|
||
return ['ok' => false, 'message' => 'PHP darf public/.htaccess nicht schreiben. Die drei Zeilen unter „HTTPS erzwingen“ dort von Hand einschalten (das # davor entfernen).'];
|
||
}
|
||
if (null !== $problem = $this->originCheck->problem($origin)) {
|
||
$this->write($before);
|
||
|
||
return ['ok' => false, 'message' => 'Mit eingeschalteter Umleitung kam '.$origin.' nicht mehr an: '.rtrim($problem, '.').'. Vermutlich meldet der Server HTTPS nicht an die .htaccess (Proxy ohne X-Forwarded-Proto). Die Datei ist zurückgesetzt, nichts geändert.'];
|
||
}
|
||
$http = 'http://'.substr($origin, 8).OriginCheck::PATH;
|
||
$answer = ($this->httpProbe)($http);
|
||
$target = $origin.OriginCheck::PATH;
|
||
if (in_array($answer['status'], [301, 308], true) && $answer['location'] === $target) {
|
||
return ['ok' => true, 'message' => 'HTTPS wird jetzt erzwungen: http:// leitet mit 301 auf '.$origin.' um. HSTS erst einschalten, wenn das ein paar Tage gut läuft.'];
|
||
}
|
||
if (0 === $answer['status']) {
|
||
return ['ok' => true, 'message' => 'Die Umleitung ist eingeschaltet, und HTTPS antwortet weiter. Ob http:// umleitet, ließ sich vom Server aus nicht prüfen ('.$answer['error'].') – einmal im Browser http:// aufrufen.'];
|
||
}
|
||
|
||
return ['ok' => true, 'message' => sprintf('Die Zeilen sind eingeschaltet, aber http:// antwortet mit %d%s statt mit 301 auf HTTPS. Der Server liest die Umleitung offenbar nicht (mod_rewrite, AllowOverride) – beim Hoster die HTTPS-Weiterleitung einschalten.', $answer['status'], '' !== $answer['location'] ? ' nach '.$answer['location'] : '')];
|
||
}
|
||
|
||
/**
|
||
* Schaltet die Umleitung aus - und HSTS mit, denn HSTS ohne Umleitung verspricht etwas,
|
||
* das der Server nicht mehr einhält.
|
||
*
|
||
* @return array{ok: bool, message: string}
|
||
*/
|
||
public function disableRedirect(): array
|
||
{
|
||
$before = $this->read();
|
||
if (null === $before) {
|
||
return ['ok' => false, 'message' => 'public/.htaccess fehlt oder ist nicht lesbar.'];
|
||
}
|
||
$hsts = self::active($before, [self::HSTS_LINE]);
|
||
$after = self::switchLines(self::switchLines($before, self::REDIRECT_LINES, false), [self::HSTS_LINE], false);
|
||
if ($after !== $before && !$this->write($after)) {
|
||
return ['ok' => false, 'message' => 'PHP darf public/.htaccess nicht schreiben.'];
|
||
}
|
||
|
||
return ['ok' => true, 'message' => 'Die Umleitung auf HTTPS ist ausgeschaltet.'.($hsts ? ' HSTS ebenfalls – Browser, die den Kopf schon kennen, bleiben aber bis zu einem Jahr bei HTTPS.' : '')];
|
||
}
|
||
|
||
/** @return array{ok: bool, message: string} */
|
||
public function switchHsts(bool $on, string $origin): array
|
||
{
|
||
$before = $this->read();
|
||
if (null === $before || !self::present($before, [self::HSTS_LINE])) {
|
||
return ['ok' => false, 'message' => 'Die HSTS-Zeile steht nicht in public/.htaccess.'];
|
||
}
|
||
if ($on) {
|
||
if (!self::active($before, self::REDIRECT_LINES)) {
|
||
return ['ok' => false, 'message' => 'HSTS erst, wenn die Umleitung auf HTTPS eingeschaltet ist.'];
|
||
}
|
||
if (null !== $problem = $this->originCheck->problem(rtrim(trim($origin), '/'))) {
|
||
return ['ok' => false, 'message' => $problem.' HSTS bleibt aus.'];
|
||
}
|
||
}
|
||
if (!$this->write(self::switchLines($before, [self::HSTS_LINE], $on))) {
|
||
return ['ok' => false, 'message' => 'PHP darf public/.htaccess nicht schreiben.'];
|
||
}
|
||
|
||
return ['ok' => true, 'message' => $on
|
||
? 'HSTS ist eingeschaltet: Browser rufen diese Domain ein Jahr lang nur noch über HTTPS auf, Subdomains eingeschlossen.'
|
||
: 'HSTS ist ausgeschaltet. Browser, die den Kopf schon kennen, bleiben bis zu einem Jahr bei HTTPS.'];
|
||
}
|
||
|
||
/**
|
||
* Die neue .htaccess eines Updates, mit den Zeilen eingeschaltet, die in der alten
|
||
* eingeschaltet waren. Archiv- und Git-Update rufen beide hierher.
|
||
*/
|
||
public static function keepEnabled(string $new, string $old): string
|
||
{
|
||
if (self::active($old, self::REDIRECT_LINES)) {
|
||
$new = self::switchLines($new, self::REDIRECT_LINES, true);
|
||
}
|
||
if (self::active($old, [self::HSTS_LINE])) {
|
||
$new = self::switchLines($new, [self::HSTS_LINE], true);
|
||
}
|
||
|
||
return $new;
|
||
}
|
||
|
||
/** @param list<string> $lines */
|
||
public static function active(string $content, array $lines): bool
|
||
{
|
||
foreach ($lines as $line) {
|
||
if (1 !== preg_match('/^[ \t]*'.preg_quote($line, '/').'/m', $content)) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/** @param list<string> $lines */
|
||
private static function present(string $content, array $lines): bool
|
||
{
|
||
foreach ($lines as $line) {
|
||
if (1 !== preg_match('/^[ \t]*(#[ \t]*)?'.preg_quote($line, '/').'/m', $content)) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/** @param list<string> $lines */
|
||
private static function switchLines(string $content, array $lines, bool $on): string
|
||
{
|
||
foreach ($lines as $line) {
|
||
$quoted = preg_quote($line, '/');
|
||
$content = $on
|
||
? (string) preg_replace('/^([ \t]*)#[ \t]*('.$quoted.')/m', '$1$2', $content)
|
||
: (string) preg_replace('/^([ \t]*)('.$quoted.')/m', '$1#$2', $content);
|
||
}
|
||
|
||
return $content;
|
||
}
|
||
|
||
private function read(): ?string
|
||
{
|
||
$content = is_file($this->file()) ? @file_get_contents($this->file()) : false;
|
||
|
||
return false === $content ? null : $content;
|
||
}
|
||
|
||
private function write(string $content): bool
|
||
{
|
||
return is_writable($this->file()) && false !== @file_put_contents($this->file(), $content, LOCK_EX);
|
||
}
|
||
|
||
/**
|
||
* Eine Anfrage über http, ohne Weiterleitungen zu folgen: gefragt ist nur, wohin sie zeigt.
|
||
*
|
||
* @return array{status: int, location: string, error: string}
|
||
*/
|
||
private static function probeHttp(string $url): array
|
||
{
|
||
if (function_exists('curl_init')) {
|
||
$curl = curl_init($url);
|
||
curl_setopt_array($curl, [
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_HEADER => true,
|
||
CURLOPT_NOBODY => false,
|
||
CURLOPT_FOLLOWLOCATION => false,
|
||
CURLOPT_PROTOCOLS => CURLPROTO_HTTP,
|
||
CURLOPT_CONNECTTIMEOUT => 5,
|
||
CURLOPT_TIMEOUT => 10,
|
||
CURLOPT_USERAGENT => 'KantSites-HTTPS-Check',
|
||
]);
|
||
$raw = curl_exec($curl);
|
||
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||
$error = curl_error($curl);
|
||
curl_close($curl);
|
||
$location = is_string($raw) && preg_match('/^Location:[ \t]*(\S+)/mi', $raw, $match) ? $match[1] : '';
|
||
|
||
return ['status' => $status, 'location' => $location, 'error' => '' !== $error ? $error : 'keine Antwort'];
|
||
}
|
||
$context = stream_context_create(['http' => ['follow_location' => 0, 'ignore_errors' => true, 'timeout' => 10, 'user_agent' => 'KantSites-HTTPS-Check']]);
|
||
$http_response_header = [];
|
||
$body = @file_get_contents($url, false, $context);
|
||
$headers = $http_response_header;
|
||
if (false === $body && [] === $headers) {
|
||
return ['status' => 0, 'location' => '', 'error' => 'keine Verbindung über http'];
|
||
}
|
||
$status = preg_match('#^HTTP/\S+\s+(\d{3})#', (string) ($headers[0] ?? ''), $match) ? (int) $match[1] : 0;
|
||
$location = '';
|
||
foreach ($headers as $header) {
|
||
if (preg_match('/^Location:[ \t]*(\S+)/i', $header, $match)) {
|
||
$location = $match[1];
|
||
}
|
||
}
|
||
|
||
return ['status' => $status, 'location' => $location, 'error' => 'keine Antwort'];
|
||
}
|
||
}
|