Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b75a16a781 | ||
|
|
9111958bcf | ||
|
|
95dc6bf0ce | ||
|
|
472abfdf89 | ||
|
|
e2780ca5f6 | ||
|
|
7a68c933ce | ||
|
|
55f8e9e84c | ||
|
|
e3cd8c6739 | ||
|
|
235a7866b3 | ||
|
|
c7585e720a |
@@ -26,6 +26,24 @@ jobs:
|
||||
coverage: xdebug
|
||||
ini-values: apc.enable_cli=1
|
||||
|
||||
# Authenticate to GitHub to raise API rate limit from 60 → 5,000 req/hour.
|
||||
# Uses the same token that publish.yaml uses to sync to GitHub.
|
||||
- name: Configure GitHub OAuth token
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.SYNC_GITHUB_TOKEN }}
|
||||
run: composer config --global github-oauth.github.com "$GITHUB_TOKEN"
|
||||
|
||||
# Cache Composer's download cache so repeated CI runs don't re-download
|
||||
# packages at all. Keyed on composer.lock hash — cache busts automatically
|
||||
# when dependencies change.
|
||||
- name: Cache Composer dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.composer/cache
|
||||
key: composer-${{ runner.os }}-${{ hashFiles('composer.lock') }}
|
||||
restore-keys: |
|
||||
composer-${{ runner.os }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-progress
|
||||
|
||||
|
||||
Executable
+265
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env bash
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# PreAuth Dev Server Script
|
||||
#
|
||||
# Manages a local PHP dev server for end-to-end development and testing.
|
||||
# Binds to 0.0.0.0 so the app is accessible via a reverse proxy (Caddy) for
|
||||
# browser-based visual verification.
|
||||
#
|
||||
# PreAuth is a TOTP-based authentication gateway. It uses APCu for nonce/cache
|
||||
# and filesystem for session persistence — no database needed. The dev server
|
||||
# runs with APP_ENV=dev and APP_DEBUG=1 for live troubleshooting.
|
||||
#
|
||||
# Self-bootstrapping: the `start` command checks for required system packages
|
||||
# (PHP, extensions, tools), Composer, and project dependencies — installing
|
||||
# them automatically if missing. This means the script works even after a
|
||||
# terminal reset/reboot, embracing the self-cleaning container design.
|
||||
#
|
||||
# Usage:
|
||||
# bin/dev.sh start Start the dev server (auto-installs deps if needed)
|
||||
# bin/dev.sh stop Stop the dev server
|
||||
# bin/dev.sh status Check if the dev server is running
|
||||
# bin/dev.sh restart Stop and start the dev server
|
||||
#
|
||||
# Port assignment (P-R-E = 7-7-3):
|
||||
# 8773 → https://preauth.lyra-dev.devgnome.com
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
# ── Configuration ───────────────────────────────────────────────────────────
|
||||
PORT=8773
|
||||
HOST="0.0.0.0"
|
||||
ENV="dev"
|
||||
DEV_SECRET="dev_secret_not_for_production_use_only"
|
||||
PID_FILE="var/.dev-server.pid"
|
||||
LOG_FILE="var/log/dev-server.log"
|
||||
|
||||
# Required PHP extensions (checked via php -m)
|
||||
REQUIRED_PHP_EXTS=(
|
||||
ctype
|
||||
iconv
|
||||
mbstring
|
||||
apcu
|
||||
dom
|
||||
SimpleXML
|
||||
xml
|
||||
)
|
||||
|
||||
# Apt packages for PHP + extensions
|
||||
# Note: preauth uses Symfony 7.4 which requires PHP >=8.1.
|
||||
# We install PHP 8.4 (available in Debian 13/Trixie) for consistency.
|
||||
PHP_APT_PACKAGES=(
|
||||
php8.4-cli
|
||||
php8.4-common # ctype, iconv
|
||||
php8.4-mbstring
|
||||
php8.4-xml # dom, SimpleXML, xml
|
||||
php8.4-opcache
|
||||
php8.4-readline
|
||||
php8.4-apcu # APCu — critical for nonce cache, rate limiter, sessions
|
||||
)
|
||||
|
||||
# System tools needed
|
||||
SYSTEM_TOOLS=(
|
||||
git
|
||||
unzip
|
||||
curl
|
||||
)
|
||||
|
||||
# Resolve project root (script lives in bin/)
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Ensure var directory structure exists
|
||||
mkdir -p var/log var/share
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
is_running() {
|
||||
if [[ ! -f "$PID_FILE" ]]; then
|
||||
return 1
|
||||
fi
|
||||
local pid
|
||||
pid="$(cat "$PID_FILE")"
|
||||
if [[ -z "$pid" ]] || ! kill -0 "$pid" 2>/dev/null; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
print_status() {
|
||||
if is_running; then
|
||||
local pid
|
||||
pid="$(cat "$PID_FILE")"
|
||||
echo "✅ PreAuth dev server is RUNNING"
|
||||
echo " PID: $pid"
|
||||
echo " URL: http://localhost:${PORT}"
|
||||
echo " Exposed: http://${HOST}:${PORT}"
|
||||
echo " Dev URL: https://preauth.lyra-dev.devgnome.com"
|
||||
echo " Logs: ${LOG_FILE}"
|
||||
else
|
||||
echo "⛔ PreAuth dev server is STOPPED"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Bootstrap ───────────────────────────────────────────────────────────────
|
||||
# Ensures all system packages, Composer, and project dependencies are present.
|
||||
# Idempotent — if everything is already installed, checks are fast no-ops.
|
||||
# This is what makes the script survive terminal resets/reboots.
|
||||
|
||||
bootstrap() {
|
||||
local needed_packages=()
|
||||
|
||||
# ── Check system tools ──
|
||||
for tool in "${SYSTEM_TOOLS[@]}"; do
|
||||
if ! command -v "$tool" &>/dev/null; then
|
||||
needed_packages+=("$tool")
|
||||
fi
|
||||
done
|
||||
|
||||
# ── Check PHP and required extensions ──
|
||||
local php_needs_install=false
|
||||
if ! command -v php &>/dev/null; then
|
||||
php_needs_install=true
|
||||
else
|
||||
for ext in "${REQUIRED_PHP_EXTS[@]}"; do
|
||||
if ! php -m 2>/dev/null | grep -iq "^${ext}$"; then
|
||||
php_needs_install=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ "$php_needs_install" == "true" ]]; then
|
||||
needed_packages+=("${PHP_APT_PACKAGES[@]}")
|
||||
fi
|
||||
|
||||
# ── Install missing packages ──
|
||||
if [[ ${#needed_packages[@]} -gt 0 ]]; then
|
||||
echo "→ Installing missing system packages: ${needed_packages[*]}…"
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq "${needed_packages[@]}"
|
||||
fi
|
||||
|
||||
# ── Ensure APCu is enabled for CLI ──
|
||||
# PreAuth's console commands need APCu; the Dockerfile sets apc.enable_cli=1
|
||||
local apcu_ini="/etc/php/8.4/mods-available/apcu.ini"
|
||||
if [[ -f "$apcu_ini" ]] && ! grep -q 'apc.enable_cli' "$apcu_ini" 2>/dev/null; then
|
||||
echo "→ Enabling APCu CLI support…"
|
||||
echo 'apc.enable_cli=1' | sudo tee -a "$apcu_ini" >/dev/null
|
||||
fi
|
||||
|
||||
# ── Ensure Composer is available ──
|
||||
if ! command -v composer &>/dev/null; then
|
||||
echo "→ Installing Composer…"
|
||||
curl -sS https://getcomposer.org/installer | php
|
||||
sudo mv composer.phar /usr/local/bin/composer
|
||||
sudo chmod +x /usr/local/bin/composer
|
||||
fi
|
||||
|
||||
# ── Ensure project dependencies are installed ──
|
||||
if [[ ! -d "vendor/" ]]; then
|
||||
echo "→ Installing Composer dependencies…"
|
||||
APP_ENV=dev composer install --no-interaction
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Commands ────────────────────────────────────────────────────────────────
|
||||
|
||||
start() {
|
||||
if is_running; then
|
||||
echo "⚠️ Dev server is already running (PID $(cat "$PID_FILE"))"
|
||||
print_status
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "→ Starting PreAuth dev server on ${HOST}:${PORT}…"
|
||||
|
||||
# Self-bootstrap: ensure all dependencies are present
|
||||
bootstrap
|
||||
|
||||
echo "→ Clearing dev cache…"
|
||||
APP_ENV="$ENV" \
|
||||
APP_DEBUG=1 \
|
||||
APP_SECRET="$DEV_SECRET" \
|
||||
php bin/console cache:clear 2>&1 | tail -3
|
||||
|
||||
echo "→ Starting PHP dev server…"
|
||||
APP_ENV="$ENV" \
|
||||
APP_DEBUG=1 \
|
||||
APP_SECRET="$DEV_SECRET" \
|
||||
APP_SHARE_DIR="${PROJECT_ROOT}/var/share" \
|
||||
nohup php -S "${HOST}:${PORT}" -t public/ > "$LOG_FILE" 2>&1 &
|
||||
|
||||
local pid=$!
|
||||
echo "$pid" > "$PID_FILE"
|
||||
|
||||
# Give it a moment to boot
|
||||
sleep 2
|
||||
|
||||
if is_running; then
|
||||
echo ""
|
||||
print_status
|
||||
else
|
||||
echo "❌ Failed to start dev server. Check logs:"
|
||||
echo " ${LOG_FILE}"
|
||||
tail -20 "$LOG_FILE" 2>/dev/null || true
|
||||
rm -f "$PID_FILE"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
stop() {
|
||||
if ! is_running; then
|
||||
echo "⚠️ Dev server is not running."
|
||||
rm -f "$PID_FILE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
local pid
|
||||
pid="$(cat "$PID_FILE")"
|
||||
echo "→ Stopping dev server (PID ${pid})…"
|
||||
kill "$pid" 2>/dev/null || true
|
||||
|
||||
# Wait for graceful shutdown
|
||||
local count=0
|
||||
while kill -0 "$pid" 2>/dev/null && [[ $count -lt 10 ]]; do
|
||||
sleep 0.5
|
||||
count=$((count + 1))
|
||||
done
|
||||
|
||||
# Force kill if still alive
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo "→ Process didn't exit gracefully, sending SIGKILL…"
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
rm -f "$PID_FILE"
|
||||
echo "✅ Dev server stopped."
|
||||
}
|
||||
|
||||
restart() {
|
||||
stop
|
||||
sleep 1
|
||||
start
|
||||
}
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
usage() {
|
||||
echo "Usage: bin/dev.sh {start|stop|status|restart}"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " start Start the dev server (auto-installs deps if needed)"
|
||||
echo " stop Stop the dev server"
|
||||
echo " status Check if the dev server is running"
|
||||
echo " restart Restart the dev server"
|
||||
exit 1
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
start) start ;;
|
||||
stop) stop ;;
|
||||
status) print_status ;;
|
||||
restart) restart ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Listener;
|
||||
|
||||
use App\Service\DomainInterface;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||
@@ -15,6 +16,11 @@ use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||
*/
|
||||
final readonly class SecurityHeadersListener
|
||||
{
|
||||
public function __construct(
|
||||
private DomainInterface $domainManager,
|
||||
) {
|
||||
}
|
||||
|
||||
#[AsEventListener(priority: 0)]
|
||||
public function onKernelResponse(ResponseEvent $event): void
|
||||
{
|
||||
@@ -36,11 +42,24 @@ final readonly class SecurityHeadersListener
|
||||
|
||||
/* Content-Security-Policy — the login page uses inline styles
|
||||
* and scripts (via Twig includes), so we allow 'unsafe-inline'
|
||||
* for those. No external resources are loaded. */
|
||||
$headers->set(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';"
|
||||
);
|
||||
* for those. No external resources are loaded.
|
||||
*
|
||||
* When subdomain redirection is off (or the request is not on
|
||||
* the auth subdomain), the login form is served inline on the
|
||||
* protected host and submission is performed via a same-origin
|
||||
* fetch() call in _script.html.twig. That fetch is blocked by
|
||||
* the default 'none' policy, so we add connect-src 'self' only
|
||||
* in that case — the least privilege needed to make the form
|
||||
* work. On the auth subdomain the form POSTs normally and no
|
||||
* inline script is included, so the stricter policy applies. */
|
||||
$inlineScript = $this->domainManager->getAuthSubdomain() !== $event->getRequest()->getHost();
|
||||
$csp = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';";
|
||||
|
||||
if ($inlineScript) {
|
||||
$csp .= " connect-src 'self';";
|
||||
}
|
||||
|
||||
$headers->set('Content-Security-Policy', $csp);
|
||||
|
||||
/* HSTS — enforce HTTPS for one year (app is designed for HTTPS behind a proxy) */
|
||||
$headers->set('Strict-Transport-Security', 'max-age=31536000');
|
||||
|
||||
@@ -79,7 +79,7 @@ final readonly class PublicPathMatcher implements PublicPathMatcherInterface
|
||||
$host = null;
|
||||
$path = $entry;
|
||||
|
||||
if (preg_match('/^([a-z0-9.-]+)(\/.+)$/i', $entry, $m)) {
|
||||
if (preg_match('/^([a-z0-9.-]+)(\/.*)$/i', $entry, $m)) {
|
||||
$host = strtolower($m[1]);
|
||||
$path = $m[2];
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<style id="preauth-style">
|
||||
* { margin: 0; padding: 0.25em; }
|
||||
html { background-color: {{ env.bg_color|e('css') }}; color: {{ env.fg_color|e('css') }}; display: table;
|
||||
html { background-color: {{ env.bg_color }}; color: {{ env.fg_color }}; display: table;
|
||||
font-family: sans-serif; font-size: 1.5em; height: 100%; padding: 0; width: 100%; }
|
||||
body { display: table-cell; vertical-align: middle; }
|
||||
h1 { font-size: 2.5em; font-weight: normal; text-align: center; }
|
||||
p { color: {{ env.error_color|e('css') }}; text-align: center; }
|
||||
p { color: {{ env.error_color }}; text-align: center; }
|
||||
form { align-items: baseline; display: flex; flex-wrap: wrap; justify-content: center; }
|
||||
form div { width: 45%; min-width: 300px; }
|
||||
div.right { text-align: right; margin-top: 1em; padding-bottom: 0 }
|
||||
|
||||
@@ -176,6 +176,25 @@ final class PublicPathMatcherTest extends TestCase
|
||||
self::assertFalse($matcher->matches('other.host', '/public/repo'));
|
||||
}
|
||||
|
||||
public function testDomainPrefixedRootPathMatchesRoot(): void
|
||||
{
|
||||
// host/ — the trailing slash is the entire path, nothing after it
|
||||
$matcher = new PublicPathMatcher('code.example.com/');
|
||||
self::assertTrue($matcher->matches('code.example.com', '/'));
|
||||
self::assertFalse($matcher->matches('code.example.com', '/public'));
|
||||
self::assertFalse($matcher->matches('other.example.com', '/'));
|
||||
}
|
||||
|
||||
public function testDomainPrefixedRootWithOtherPatterns(): void
|
||||
{
|
||||
// The exact scenario from the bug report
|
||||
$matcher = new PublicPathMatcher('code.example.com/,code.example.com/public/**');
|
||||
self::assertTrue($matcher->matches('code.example.com', '/'));
|
||||
self::assertTrue($matcher->matches('code.example.com', '/public/repo'));
|
||||
self::assertFalse($matcher->matches('code.example.com', '/private'));
|
||||
self::assertFalse($matcher->matches('other.example.com', '/'));
|
||||
}
|
||||
|
||||
public function testDomainPrefixIsCaseInsensitive(): void
|
||||
{
|
||||
$matcher = new PublicPathMatcher('Code.Example.COM/public/**');
|
||||
|
||||
Reference in New Issue
Block a user