Skip to content

Issue 129 Every Page Returned HTTP 500

Ed Mozley edited this page Oct 2, 2026 · 3 revisions

Every page returned HTTP 500 after upgrading (issue #129)

Reported by tjedelhauser Β· Fixed in #1457/#1458


1. What you saw

You pulled the latest main, rebuilt, and the whole product was gone. Not degraded β€” gone. Every URL answered with HTTP 500 and a zero-byte body: the analyst login, the self-service portal, the landing page, the API. Nothing rendered, nothing was logged, and there was no message anywhere to act on.

GET /                        500   0 bytes
GET /auth/login.php          500   0 bytes
GET /self-service/login.php  500   0 bytes

The underlying error, which you had to go and find for yourself:

PHP Fatal error: Uncaught Error: Call to undefined function dbConnectionOptions()
in /var/www/html/includes/functions.php:59

2. What was actually wrong

Update #1446 pinned every database connection to UTC. It did so by adding one function:

function dbConnectionOptions(): array
{
    return [ PDO::MYSQL_ATTR_INIT_COMMAND => "SET time_zone = '+00:00'" ];
}

...and changing all eleven places in the product that open a PDO connection to pass it.

The function was put in config.php.

πŸ”΄ config.php is not ours

That is the entire bug. config.php is the operator's file:

  • It ships as a template, with a developer's own C:\wamp64\db_config.php path in it. Every install must edit it before the product runs at all.
  • Having edited it, operators keep their copy across upgrades. That is the documented and sensible thing to do.
  • The Docker image does not even leave it to chance β€” the Dockerfile copies docker/config.php straight over the top of it:
COPY docker/config.php /var/www/html/config.php

So upgrading delivered the eleven callers and left the definition behind. docker/config.php was never updated in #1446, which is why Docker broke 100% of the time; a hand-installed site broke the moment it preserved its own config.php, which is every hand-installed site that had ever been configured.

PHP resolves a function name when it reaches the call, so config.php parsed fine, functions.php parsed fine, and the process died at the first attempt to open a database connection β€” which is to say, on every request the product can serve.

Why it was not caught

The development machine's config.php is the repository's config.php. On the one install in the world where that file is not customised, the function was present and everything worked.


3. πŸ”‘ The rule this broke

Nothing the application has to execute may live in config.php.

That file is for values the operator chooses β€” credentials, paths, switches. Behaviour lives in includes/, which upgrades with the product.

A function defined in config.php is invisible to every install that kept its own copy.


4. The fix

The definition moved to a new file, includes/db.php, which upgrades with the product like everything else under includes/. The rule above is written at the top of it, so the next person tempted to reach for config.php reads why not.

It is declared behind a guard:

if (!function_exists('dbConnectionOptions')) {
    function dbConnectionOptions(): array { ... }
}

That is an upgrade path, not defensiveness. An install coming from #1446 still has the copy in its own config.php, and every caller loads config.php first. Theirs wins; this fills the hole for everybody else. Without the guard, those installs would trade a fatal undefined function for a fatal cannot redeclare β€” the same outage with a different message.

includes/functions.php requires it, which covers seven of the eleven callers. The four sign-in and password-reset paths that deliberately do not load functions.php require it directly.

The same fault, one file away

includes/ssl.php β€” which defines sslApplyCurl(), the helper every outbound HTTPS request in the product calls β€” is also reached only through config.php. It has 43 callers and only five of them guard with function_exists(). An operator whose config.php predates the SSL work is one identical fatal away from the same outage.

functions.php now requires that too, so it no longer depends on the operator's file either.


5. Why the container told you nothing

Diagnosing this meant reading our source, because docker compose logs showed a 500 in the access log and not one word about the error.

The image sets no value for log_errors, and PHP's built-in default is off. So in the Docker image, no PHP error has ever been written anywhere at all.

log_errors = On       ; goes to stderr, where Docker collects it
display_errors = Off  ; and stays out of the browser

display_errors was already switched off β€” but in docker/config.php, with ini_set(), which only takes effect once config.php has run. A failure earlier than that would have printed file paths and a stack trace into a visitor's page. Setting it in php.ini covers the whole request. This particular fatal did not leak, as it happens: it was in functions.php, after config.php had run. A parse error in config.php itself would have.


6. πŸ“ Files

File Change
includes/db.php New. Canonical dbConnectionOptions(), guarded, with the rule documented
config.php Definition removed; replaced by a comment saying where it went and why
includes/functions.php Requires db.php and ssl.php
api/auth/request_password_reset.php Requires db.php
api/auth/reset_password.php Requires db.php
auth/oauth_callback.php Requires db.php
auth/google_oauth_callback.php Requires db.php
docker/php.ini log_errors = On, display_errors = Off
11 call sites Comment repointed from config.php to includes/db.php

docker/config.php was deliberately not given the function. Adding it there would have fixed Docker and left every hand-installed site broken β€” the same mistake in the other direction.


7. How it was verified

Reproduced first, against the real container, before anything was changed:

GET /                        500   0 bytes
GET /auth/login.php          500   0 bytes
GET /self-service/login.php  500   0 bytes

require "config.php"; var_dump(function_exists("dbConnectionOptions"));
bool(false)

After the fix, on a rebuilt image:

302  /                                200  /setup/index.php
200  /auth/login.php                  200  /api/auth/reset_password.php
200  /self-service/login.php          200  /auth/oauth_callback.php
302  /self-service/register.php       200  /auth/google_oauth_callback.php

PHP errors logged during the sweep: none

The pinning still works

A fix that quietly dropped the UTC pinning would test exactly like a fix that kept it β€” every page would load either way. So it was asserted directly, against a server whose own clock is not pinned:

session time_zone  : +00:00     <- ours
global  time_zone  : SYSTEM     <- the server's
NOW()              : 2026-09-03 23:02:35
UTC_TIMESTAMP()    : 2026-09-03 23:02:35

The same on the hand-installed development machine, which no longer has the function in its config.php at all.

All three upgrade paths

Starting point Result
Fresh Docker (no function in config.php) includes/db.php supplies it βœ…
Hand install with the new config.php includes/db.php supplies it βœ…
Upgrading from #1446, old config.php still defines it Guard holds, no redeclare βœ…

The logging was proved by breaking something

A file calling an undefined function was dropped into the container on purpose. Before: HTTP 200, the error printed into the response body, nothing in the log. After: HTTP 500, empty body, and the fatal in docker compose logs. An ini setting that reads On is not evidence that anything is actually written.


8. What this means for you

Replace your files and it is fixed. There is nothing to edit.

  • Keeping your own config.php, as you should: the function now arrives with the product.
  • Already carrying the #1446 copy in your config.php: it keeps working. You may delete it if you like; you do not have to.
  • Docker: rebuild the image.

Nothing was written to the database while this was happening, because nothing could open a connection. There is no data to repair.


9. It happened again - and this page had already named the files

19 September 2026. A user running 1.9.0 connected a Microsoft 365 mailbox and got:

Fatal error: Uncaught Error: Call to undefined function sslApplyCurl()
 in auth/oauth_callback.php:119

Same rule broken, different function. sslApplyCurl() lives in includes/ssl.php, and auth/oauth_callback.php loaded config.php, includes/db.php and includes/encryption.php - but never includes/ssl.php. It had been reaching the definition only because the operator's config.php requires that file. On an install whose config.php does not, there is no definition to reach.

His config.php is missing the whole block, not just the require. The debug tool D006 reported SSL_CA_BUNDLE undefined, and config.php defines that constant two lines below the require - so a file missing only the require would fatal inside config.php on every page instead, and his application worked. The block has been in the shipped template since 22 July 2026, before the 1.0.0 tag, so no released version lacks it; his copy is hand-assembled or predates 1.0.0.

The part worth being uncomfortable about

Section 6 of this page already named the two files. It listed auth/oauth_callback.php and auth/google_oauth_callback.php among "the four that deliberately do not load functions.php", gave them db.php, and never gave them ssl.php. The risk was identified, written down, and left open for two weeks.

A noted risk is not a fixed one.

His own fix was incomplete

He added require_once __DIR__ . '/ssl.php'; to includes/mailbox_graph.php, and his Microsoft sync started working. Two problems:

  • includes/mailbox_graph.php is one of ours. His edit disappears on his next upgrade, taking his working mailbox with it.
  • It only worked by include order - oauth_callback.php requires mailbox_graph.php at line 14, before line 119 runs. auth/google_oauth_callback.php never requires mailbox_graph.php at all, so Gmail produced the identical fatal and would have stayed broken.

What changed

  • Both callbacks now require_once includes/ssl.php themselves.
  • Both definitions in includes/ssl.php gained the function_exists() guard includes/db.php has had since #1446, for the same upgrade-path reason given in section 4.
  • sslApplyCurl() attached a CA bundle only when SSL_CA_BUNDLE was defined - again the operator's constant. With the fatal cleared, the reproduction's very next error was unable to get local issuer certificate. It now falls back to sslResolveCaBundle(), the same resolver config.php would have called. An operator who sets SSL_CA_BUNDLE still wins.

Why the guard test did not catch it

tests/config-not-load-bearing.php asked "does this function have a home under includes/?" - and its probe required includes/ssl.php itself. It never asked the question that matters: "does each caller load that home?" The guard had the same blind spot as the bug.

It now walks the include graph of every directly-requestable caller with config.php's own edges cut out. That detail is the whole test: an earlier hand audit let paths run through config.php and therefore cleared auth/oauth_callback.php, the one file already known to be broken. If the operator's file is what carries you to the definition, you have proved the bug, not its absence. It uses PHP's tokeniser rather than a regex, because sslApplyCurl() appears inside a description string in the debug-tool registry and a regex audit reported that file as a broken caller.

Proved by reintroducing the fault: with the two require_once lines removed the suite fails and names both callbacks.

D006 was reporting a healthy install

Worse than not catching it, the diagnostic said everything was fine. D006 printed:

includes/ssl.php loaded : YES
...
βœ“ Working. Certificate verification is on and succeeded against 6 of 6 services.

on the same machine, at the same time, as the fatal. It computed that line from function_exists('sslApplyCurl') having itself required includes/functions.php, which requires includes/ssl.php - so the line could only ever say YES. It measured its own include path, not the one that was broken.

D006 now has a Where sslApplyCurl() comes from section that asks the structural question, reports whether your config.php still carries the SSL block, prints the lines to add when it does not, and refuses to show an unqualified tick when it has found something the live requests cannot exercise.

The general lesson, for any diagnostic: a check whose answer cannot come back negative is not a check. Ask what would have to be true for this line to print NO, and if nothing would, the line is decoration.


10. The last one: BASE_URL - and a tool that checks your config.php (3.0.0)

After dbConnectionOptions() and sslApplyCurl() there was one thing left that the app could not run without and that only config.php provided: BASE_URL, the app's web path (/freeitsm-app/, /). It is used in 300+ places with no fallback, and on PHP 8 an undefined constant is a thrown Error, so a config.php without the block would take down every page at once - the same blast radius as #129.

Nobody has hit it. Every released config.php has the block (it predates v1.0.0). This is insurance against a hand-assembled or very old copy, fixed before it happened rather than after.

The fallback. The detection moved into includes/base_url.php, which ships with the app:

if (!defined('BASE_URL')) {
    define('BASE_URL', appBaseUrlDetect());   // where the app sits under DOCUMENT_ROOT
}

includes/functions.php loads it first, and the four entry points that do not load functions.php (the two OAuth callbacks, problem-management/new/index.php, lms/native-player.php) load it themselves. Both templates now just require_once it. Your own value always wins - set define('BASE_URL', '/helpdesk/'); above that line, or anywhere in config.php, and the fallback stands aside.

The guard. tests/config-not-load-bearing.php section 7 uses the same include walker as section 5, with config.php's edges cut: every directly-requestable file that uses BASE_URL must reach includes/base_url.php without going through config.php (228 entry points). Positive controls: a config.php with no BASE_URL still gets the right path, and an operator's own value is not overridden. Proved live too: with the line removed from a real config.php, 13 pages including the four special files loaded with correct links.

D017 - is anything missing from my config.php?

The other half of the problem: an upgrade never adds a line to your config.php, and until now nothing told you whether your copy was missing something. System β†’ Debug Tools β†’ D017 does, setting by setting - needed, has a default, or optional - with the line to add for each.

Three decisions shaped it:

  1. It never prints a value. docker/config.php defines a real DB_PASSWORD. The file is read with the tokeniser for the names it define()s (so a commented-out line does not count) and the functions it declares - nothing it assigns is echoed.
  2. It does not diff against docker/config.php. That file defines database credentials and TRUST_PROXY_HTTPS, which a hand install must not copy.
  3. No second copy to keep in sync. The list lives in includes/config_requirements.php - per setting: needed / has a default / optional, the file that copes when it is absent, what happens without it, and the line to add. Section 8 of the test holds it to both templates: every constant either template defines must be on the list, docker/config.php must define every "needed" one, and every "has a default" entry must really have a defined('NAME') check in the shipped file it names. Add a constant to a template and forget the list, and the test fails.

Positive controls in the test: a planted config with an unknown constant and a function is reported as both; a commented-out define is ignored; planted values never appear in the report. The live tool is fetched over HTTP as an administrator and the real database password is asserted absent from its output; without a session it returns 403.


11. Related

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally