Lint / PHP (phpcs PSR-12) (push) Successful in 51s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 20s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m17s
Lint / Deploy (push) Successful in 7s
First step of the remote MCP server: mcp/server.php serves /mcp over Streamable HTTP via the official MCP PHP SDK (mcp/sdk, pinned to exactly 0.8.1 since it breaks BC in nearly every minor release), with Authelia as the OAuth authorization server. No tools yet: this phase only stands up authentication, RFC 9728 Protected Resource Metadata, and routing. - Composer is introduced for the MCP endpoint ONLY: nothing else loads vendor/autoload.php, so a failed composer install at deploy time can only take /mcp down. vendor/ is gitignored and excluded from phpcs; composer.lock is resolved for PHP 8.2 so it installs on 8.2 and 8.4. - Tokens are validated against Authelia's JWKS (cached) for signature, issuer, audience == MCP_RESOURCE_URL (a beta token is rejected by prod and vice versa), and expiry. scopeClaim is 'scp' because Authelia puts scopes in an array claim of that name, not the standard 'scope'. - The request URI's scheme/host are pinned to MCP_RESOURCE_URL before the SDK sees it: TLS ends at the proxy, so PHP sees http and a client-controlled Host, and the SDK builds the 401 challenge's resource_metadata URL from that. preserveHost keeps the real Host header for the DNS-rebinding check, which only allows the canonical hostname (so direct-by-IP access is refused too). - Identity will come only from the token; this entrypoint never reads Remote-* headers or $_SESSION, since /mcp is exempt from forward-auth at the proxy and those headers are client-controlled there. Verified locally with PHP's built-in server: unauthenticated POST gets 401 + WWW-Authenticate with the https resource_metadata URL and scopes; metadata served at both /.well-known/oauth-protected-resource/mcp and the root form; malformed token -> 401 invalid_token; foreign Host and direct-IP Host -> 403; a forged Remote-User header without a token is still 401. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
194 lines
9.1 KiB
PHP
194 lines
9.1 KiB
PHP
<?php
|
|
|
|
// Load environment variables
|
|
$envFile = __DIR__ . '/../.env';
|
|
if (!file_exists($envFile)) {
|
|
die('Configuration error: .env file not found. Copy .env.example to .env and configure your database settings.');
|
|
}
|
|
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
|
|
if (!is_array($envVars)) {
|
|
die('Configuration error: .env file could not be parsed. Check for unquoted special characters (e.g. #, ;, =, or quotes) in values and wrap affected values in double quotes.');
|
|
}
|
|
|
|
// Strip quotes from values if present (parse_ini_file may include them)
|
|
if ($envVars) {
|
|
foreach ($envVars as $key => $value) {
|
|
if (is_string($value)) {
|
|
if (
|
|
(substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
|
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")
|
|
) {
|
|
$envVars[$key] = substr($value, 1, -1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Global configuration
|
|
$GLOBALS['config'] = [
|
|
// Application identity
|
|
'APP_NAME' => $envVars['APP_NAME'] ?? 'TINKER TICKETS',
|
|
'APP_SUBTITLE' => $envVars['APP_SUBTITLE'] ?? 'LotusGuild Infrastructure',
|
|
'APP_VERSION' => $envVars['APP_VERSION'] ?? '1.2',
|
|
|
|
// Asset cache-busting version — auto-computed from key asset mtimes so
|
|
// browsers always pick up changes on deploy. Override via ASSET_VERSION in .env.
|
|
'ASSET_VERSION' => (function () use ($envVars) {
|
|
if (!empty($envVars['ASSET_VERSION'])) {
|
|
return $envVars['ASSET_VERSION'];
|
|
}
|
|
$files = [
|
|
__DIR__ . '/../assets/css/base.css',
|
|
__DIR__ . '/../assets/css/dashboard.css',
|
|
__DIR__ . '/../assets/css/ticket.css',
|
|
__DIR__ . '/../assets/js/base.js',
|
|
__DIR__ . '/../assets/js/dashboard.js',
|
|
__DIR__ . '/../assets/js/ticket.js',
|
|
];
|
|
$mtime = 0;
|
|
foreach ($files as $f) {
|
|
if (file_exists($f)) {
|
|
$mtime = max($mtime, filemtime($f));
|
|
}
|
|
}
|
|
return $mtime ?: '20260329';
|
|
})(),
|
|
|
|
// Canonical ticket statuses — single source of truth used by views and JS
|
|
'TICKET_STATUSES' => ['Open', 'Pending', 'In Progress', 'Closed'],
|
|
|
|
// Database settings
|
|
'DB_HOST' => $envVars['DB_HOST'] ?? 'localhost',
|
|
'DB_USER' => $envVars['DB_USER'] ?? 'root',
|
|
'DB_PASS' => $envVars['DB_PASS'] ?? '',
|
|
'DB_NAME' => $envVars['DB_NAME'] ?? 'tinkertickets',
|
|
|
|
// Trusted reverse proxies. Authelia forward-auth (Remote-* headers) is only
|
|
// honored when REMOTE_ADDR is in this allowlist, so the spoofable identity
|
|
// headers can't be set by anything that reaches PHP directly. Comma-separated
|
|
// IPs in .env (e.g. TRUSTED_PROXIES=10.10.10.27). Empty = enforcement OFF
|
|
// (backward compatible — relies solely on network topology).
|
|
'TRUSTED_PROXIES' => array_values(array_filter(array_map(
|
|
'trim',
|
|
explode(',', (string)($envVars['TRUSTED_PROXIES'] ?? ''))
|
|
), fn($ip) => $ip !== '')),
|
|
|
|
// URL settings
|
|
'BASE_URL' => '', // Empty since we're serving from document root
|
|
'ASSETS_URL' => '/assets', // Assets URL
|
|
'API_URL' => '/api', // API URL
|
|
|
|
// Matrix webhook (hookshot generic webhook URL)
|
|
'MATRIX_WEBHOOK_URL' => $envVars['MATRIX_WEBHOOK_URL'] ?? null,
|
|
// Comma-separated Matrix user IDs to @mention on new tickets / status changes (e.g. @jared:matrix.lotusguild.org)
|
|
'MATRIX_NOTIFY_USERS' => $envVars['MATRIX_NOTIFY_USERS'] ?? '',
|
|
// Matrix homeserver domain (e.g. matrix.lotusguild.org) — used to construct Matrix user IDs
|
|
'MATRIX_DOMAIN' => $envVars['MATRIX_DOMAIN'] ?? null,
|
|
// Internal Synapse client-API base URL (e.g. http://10.10.10.29:8008) — used to verify user existence via Admin API
|
|
'SYNAPSE_ADMIN_URL' => $envVars['SYNAPSE_ADMIN_URL'] ?? null,
|
|
// Synapse admin access token (generate with: register_new_matrix_user or admin API)
|
|
'SYNAPSE_ADMIN_TOKEN' => $envVars['SYNAPSE_ADMIN_TOKEN'] ?? null,
|
|
// Set to '1' or 'true' to send a notification when any comment is posted
|
|
'MATRIX_NOTIFY_COMMENTS' => filter_var($envVars['MATRIX_NOTIFY_COMMENTS'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
|
// Set to '1' or 'true' to send a notification when a ticket is assigned
|
|
'MATRIX_NOTIFY_ASSIGNMENTS' => filter_var($envVars['MATRIX_NOTIFY_ASSIGNMENTS'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
|
|
|
// Domain settings for external integrations (webhooks, links, etc.)
|
|
// Set APP_DOMAIN in .env to override
|
|
'APP_DOMAIN' => $envVars['APP_DOMAIN'] ?? null,
|
|
// Allowed hosts for HTTP_HOST validation (comma-separated in .env)
|
|
'ALLOWED_HOSTS' => array_filter(array_map(
|
|
'trim',
|
|
explode(',', $envVars['ALLOWED_HOSTS'] ?? 'localhost,127.0.0.1')
|
|
)),
|
|
|
|
// MCP endpoint (mcp/server.php). MCP_RESOURCE_URL is this server's
|
|
// canonical URL: access tokens must carry it as their audience, so a
|
|
// beta token can't be replayed against prod. MCP_OAUTH_ISSUER is the
|
|
// Authelia issuer that signs those tokens.
|
|
'MCP_RESOURCE_URL' => $envVars['MCP_RESOURCE_URL']
|
|
?? (!empty($envVars['APP_DOMAIN']) ? 'https://' . $envVars['APP_DOMAIN'] . '/mcp' : null),
|
|
'MCP_OAUTH_ISSUER' => $envVars['MCP_OAUTH_ISSUER'] ?? 'https://auth.lotusguild.org',
|
|
|
|
// Session settings
|
|
'SESSION_TIMEOUT' => 18000, // 5 hours in seconds
|
|
'SESSION_REGENERATE_INTERVAL' => 300, // Regenerate session ID every 5 minutes
|
|
|
|
// How often an already-logged-in session re-validates Remote-User/
|
|
// Remote-Groups against current Authelia/LLDAP state (AuthMiddleware).
|
|
// Without this, a revoked admin keeps full access for up to SESSION_TIMEOUT.
|
|
'PRIVILEGE_RESYNC_INTERVAL' => 300, // 5 minutes
|
|
|
|
// CSRF settings
|
|
'CSRF_LIFETIME' => 3600, // 1 hour in seconds
|
|
|
|
// Pagination settings
|
|
'PAGINATION_DEFAULT' => 15, // Default items per page
|
|
'PAGINATION_MAX' => 100, // Maximum items per page
|
|
|
|
// File upload settings
|
|
'MAX_UPLOAD_SIZE' => 10485760, // 10MB in bytes
|
|
'MAX_ATTACHMENTS_PER_TICKET' => 50,
|
|
'MAX_TOTAL_ATTACHMENT_SIZE_PER_TICKET' => 104857600, // 100MB in bytes
|
|
'ALLOWED_FILE_TYPES' => [
|
|
'image/jpeg',
|
|
'image/png',
|
|
'image/gif',
|
|
'image/webp',
|
|
'application/pdf',
|
|
'text/plain',
|
|
'text/csv',
|
|
'application/msword',
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
'application/vnd.ms-excel',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
'application/zip',
|
|
'application/x-7z-compressed',
|
|
'application/x-tar',
|
|
'application/gzip'
|
|
],
|
|
'UPLOAD_DIR' => __DIR__ . '/../uploads',
|
|
|
|
// Rate limiting (requests per minute; read by RateLimitMiddleware)
|
|
'RATE_LIMIT_DEFAULT' => (int)($envVars['RATE_LIMIT_DEFAULT'] ?? 100), // Session-based, general endpoints
|
|
'RATE_LIMIT_API' => (int)($envVars['RATE_LIMIT_API'] ?? 60), // Session-based, API endpoints
|
|
|
|
// Audit log settings
|
|
'AUDIT_LOG_RETENTION_DAYS' => 90,
|
|
|
|
// Timezone settings
|
|
// Default: America/New_York (EST/EDT)
|
|
// Common options: America/Chicago (CST), America/Denver (MST), America/Los_Angeles (PST), UTC
|
|
'TIMEZONE' => $envVars['TIMEZONE'] ?? 'America/New_York',
|
|
'TIMEZONE_OFFSET' => null, // Will be calculated below
|
|
|
|
// LDAP / lldap settings (for user avatar lookups). Connects over LDAPS
|
|
// (see api/user_avatar.php) — lldap's default LDAPS port is 6360, not
|
|
// its plaintext port 3890. The bind password must never go over the
|
|
// wire unencrypted, so this is not configurable back to a plaintext
|
|
// ldap:// connection.
|
|
//
|
|
// LDAP_HOST must be a hostname matching the LDAPS cert's *.lotusguild.org
|
|
// CN/SAN, not a bare IP — PHP's ldap extension verifies the cert's
|
|
// hostname by default and a mismatch fails the connection. Pi-hole has a
|
|
// split-horizon override so ldap.lotusguild.org resolves internally to
|
|
// the real LDAP server IP (its public DNS record points elsewhere).
|
|
'LDAP_HOST' => $envVars['LDAP_HOST'] ?? 'ldap.lotusguild.org',
|
|
'LDAP_PORT' => (int)($envVars['LDAP_PORT'] ?? 6360),
|
|
'LDAP_BIND_DN' => $envVars['LDAP_BIND_DN'] ?? 'uid=tinker-tickets,ou=people,dc=example,dc=com',
|
|
'LDAP_BIND_PW' => $envVars['LDAP_BIND_PW'] ?? '',
|
|
'LDAP_BASE_DN' => $envVars['LDAP_BASE_DN'] ?? 'dc=example,dc=com',
|
|
'LDAP_USER_BASE' => $envVars['LDAP_USER_BASE'] ?? 'ou=people,dc=example,dc=com',
|
|
'LDAP_ENABLED' => filter_var($envVars['LDAP_ENABLED'] ?? 'true', FILTER_VALIDATE_BOOLEAN),
|
|
'AVATAR_CACHE_DIR' => __DIR__ . '/../uploads/avatars',
|
|
'AVATAR_CACHE_TTL' => (int)($envVars['AVATAR_CACHE_TTL'] ?? 3600), // seconds
|
|
];
|
|
|
|
// Set PHP default timezone
|
|
date_default_timezone_set($GLOBALS['config']['TIMEZONE']);
|
|
|
|
// Calculate UTC offset for JavaScript (in minutes, negative for west of UTC)
|
|
$now = new DateTime('now', new DateTimeZone($GLOBALS['config']['TIMEZONE']));
|
|
$GLOBALS['config']['TIMEZONE_OFFSET'] = $now->getOffset() / 60; // Convert seconds to minutes
|
|
$GLOBALS['config']['TIMEZONE_ABBREV'] = $now->format('T'); // e.g., "EST", "EDT"
|