Admin Daemon

June 22, 2026 · View on GitHub

The Admin Daemon (scripts/admin_daemon.php) is a long-running privileged process that acts as the single owner of config files, log files, and system operations that the PHP web process (running under php-fpm) cannot perform directly. The web process communicates with it over a local socket.


Role in the Configuration System

The Admin Daemon is the write path for privileged config files. It plays no role in how application code reads configuration at runtime.

When a feature needs to check a setting — for example, whether the AI assistant is enabled — it reads the relevant config class directly (BbsConfig::getConfig(), AppearanceConfig::getConfig(), etc.). Those classes load their JSON files on first access and cache the result in a static property. The Admin Daemon is not involved in any of this.

The daemon becomes involved only when a value needs to be changed:

  • The admin UI sends a POST to an admin API route.
  • The route calls an AdminDaemonClient setter, which delivers the new value to the daemon over the local socket.
  • The daemon writes the config file (which it owns) and, where needed, signals the affected service to reload.

For a full description of how all configuration layers are read at runtime, see ConfigurationSystem.md.


Why It Exists

php-fpm web workers run as an unprivileged user with restricted filesystem access. They cannot write config files, write to log files managed by other processes, send signals to other daemons, or fork subprocesses safely. The Admin Daemon solves this by:

  • Running as a privileged user that owns the relevant files and processes
  • Accepting authenticated JSON commands over a local TCP or Unix socket
  • Executing the requested operations and returning structured results

Architecture

Web process (php-fpm)                Admin Daemon (admin_daemon.php)
─────────────────────                ──────────────────────────────
AdminDaemonClient ──TCP/Unix──▶ AdminDaemonServer
                  ◀──────────── JSON response

Logger (fallback) ──UDP──────▶ AdminDaemonServer (UDP log listener)

The daemon binds two sockets on the same port:

  • TCP (or Unix): authenticated command channel
  • UDP (same host:port): fire-and-forget log channel used as a fallback when the Logger cannot write a log file directly

On Linux/macOS the daemon forks a child process for each client connection so long-running commands (e.g. binkp polls) do not block the accept loop. On Windows, commands are handled synchronously.


Configuration

Environment VariableDefaultDescription
ADMIN_DAEMON_SECRET(required)Shared secret used to authenticate clients
ADMIN_DAEMON_SOCKETtcp://127.0.0.1:9065Socket target (tcp://host:port or unix:///path.sock)
ADMIN_DAEMON_PID_FILEdata/run/admin_daemon.pidPID file path
ADMIN_DAEMON_SOCKET_PERMS(none)Octal permissions to apply to a Unix socket (e.g. 0660)

Unix sockets are not supported on Windows; use a tcp:// target there.


Starting the Daemon

# Foreground (development)
php scripts/admin_daemon.php

# Background (production)
php scripts/admin_daemon.php --daemon

# Common options
php scripts/admin_daemon.php \
  --socket=tcp://127.0.0.1:9065 \
  --secret=mysecret \
  --log-level=DEBUG \
  --pid-file=data/run/admin_daemon.pid \
  --daemon
OptionDescription
--socket=TARGETOverride socket target
--secret=SECRETOverride ADMIN_DAEMON_SECRET
--log-file=FILELog file path (default: data/logs/admin_daemon.log)
--log-level=LEVELDEBUG, INFO, WARNING, ERROR, CRITICAL
--no-consoleSuppress console output
--pid-file=FILEPID file path
--socket-perms=MODEUnix socket permissions (octal)
--daemonFork into background (requires pcntl)

Wire Protocol

All communication is newline-delimited JSON over a persistent TCP (or Unix) connection.

Authentication

The first message sent by the client must be an auth frame:

{"auth": "shared-secret-value"}

The server responds:

{"ok": true}

If authentication fails, the server responds with {"ok": false, "error": "unauthorized"} and closes the connection.

Commands

After authentication, the client sends one command per connection:

{"cmd": "command_name", "data": { ... }}

The server responds:

{"ok": true, "result": { ... }}

On error:

{"ok": false, "error": "error_description"}

The client closes the connection after receiving the response (one command per connection).

UDP Log Packets

The daemon also listens on the same host:port over UDP for log messages from processes that cannot make a TCP connection. The binary packet layout is:

FieldTypeDescription
Timestampuint64 big-endianUnix time in milliseconds
Leveluint80=DEBUG, 1=INFO, 2=WARNING, 3=ERROR
PIDuint32 big-endianSending process ID
Filename lengthuint8Length of log filename
FilenamebytesLog filename (e.g. server.log)
Message lengthuint16 big-endianLength of message
MessagebytesPre-formatted log line (max 1200 bytes)

Only filenames in AdminDaemonServer::UDP_ALLOWED_LOG_FILES are accepted:

  • server.log
  • packets.log
  • multiplexing-server.log
  • binkp_poll.log
  • binkp_server.log
  • binkp_scheduler.log
  • admin_daemon.log
  • mrc_daemon.log
  • ai_bot_daemon.log
  • crashmail.log
  • dosdoor.log
  • packetbbs.log

When adding a new log file that needs UDP fallback support, add its basename to that constant.


AdminDaemonClient

src/Admin/AdminDaemonClient.php is the PHP client used by web routes and CLI scripts to communicate with the daemon.

Instantiation

$client = new \BinktermPHP\Admin\AdminDaemonClient();
// Uses ADMIN_DAEMON_SOCKET and ADMIN_DAEMON_SECRET from environment.

$client->serverLog('INFO', 'Packet processed', ['packet_id' => $packetId]);
$client->close();

// Or override socket/secret:
$client = new \BinktermPHP\Admin\AdminDaemonClient('tcp://127.0.0.1:9065', 'mysecret');
$client->serverLog('WARNING', 'Unexpected state', ['context' => 'value']);
$client->close();

One-shot logging shortcut

For simple log-and-forget use from a static context:

AdminDaemonClient::log('WARNING', 'Something happened', ['user_id' => 42]);
// Falls back to error_log() if the daemon is unreachable.

Closing the connection

$client->close();

The client automatically closes after each sendCommand() call. Call close() explicitly only when you hold the client across multiple calls and need to release the socket.


Command Reference

Packet Processing

Command (client method)Description
processPackets()Run scripts/process_packets.php synchronously
crashmailPoll()Run scripts/crashmail_poll.php synchronously

Binkp Polling

Command (client method)Description
binkPoll(string $upstream)Spawn binkp_poll.php in background; returns immediately. Pass 'all' for all uplinks.
binkPollSync(string $upstream)Run binkp_poll.php synchronously; waits for completion
binkpAuthTest(string $domain)Test binkp authentication for an uplink by domain name
binkpAuthTestAddress(string $address)Test binkp authentication for an uplink by address
reloadBinkpConfig()Send SIGHUP to the running binkp server to reload its config

Configuration — BBS & System

Command (client method)Description
getBbsConfig()Read data/bbs.json
setBbsConfig(array $config)Write data/bbs.json
getSystemConfig()Read the system section of config/binkp.json
setSystemConfig(array $config)Write the system section of config/binkp.json
getBinkpConfig()Read the binkp section of config/binkp.json
setBinkpConfig(array $config)Write the binkp section of config/binkp.json
getFullBinkpConfig()Read all sections of config/binkp.json
setFullBinkpConfig(array $config)Write all sections of config/binkp.json (deep-merges with existing)
saveLovlyNetConfig(string $json)Write config/lovlynet.json
getAppearanceConfig()Read appearance/theme settings
setAppearanceConfig(array $config)Write appearance/theme settings
getMrcConfig()Read MRC daemon config
setMrcConfig(array $config)Write MRC daemon config
getMatterbridgeConfig()Read Matterbridge config
setMatterbridgeConfig(array $config)Write Matterbridge config
getWeatherConfig()Read config/weather.json
saveWeatherConfig(string $json)Write config/weather.json

Doors Configuration

Command (client method)Description
getWebdoorsConfig()Read WebDoors configuration
saveWebdoorsConfig(string $json)Write and apply WebDoor manifest config
activateWebdoorsConfig()Activate pending WebDoors config changes
getJsdosdoorsConfig()Read JS-DOS doors configuration
saveJsdosdoorsConfig(string $json)Write JS-DOS doors configuration
activateJsdosdoorsConfig()Activate pending JS-DOS config changes
getDosdoorsConfig()Read DOS doors configuration
saveDosdoorsConfig(string $json)Write DOS doors configuration
getNativeDoorsConfig()Read native doors configuration
saveNativeDoorsConfig(string $json)Write native doors configuration
saveJsdosSharedFile(string $gameId, string $dosPath, string $contentB64, bool $deleted)Write a shared JS-DOS save file (admin-only mode)

Content & Templates

Command (client method)Description
getTaglines()Read taglines config
saveTaglines(string $text)Write taglines
listCustomTemplates()List available custom Twig template overrides
getCustomTemplate(string $path)Read a custom template file
saveCustomTemplate(string $path, string $content)Write a custom template file
deleteCustomTemplate(string $path)Delete a custom template file
installCustomTemplate(string $source, bool $overwrite)Install a template from a source path
getFileAreaRulesConfig()Read file area automation rules
saveFileAreaRulesConfig(string $json)Write file area automation rules

Terminal Screens & Art

Command (client method)Description
listShellArt()List uploaded shell art files
uploadShellArt(string $contentBase64, string $name, string $originalName)Upload a shell art file
deleteShellArt(string $name)Delete a shell art file
listTerminalScreens()List terminal screen files
getTerminalScreen(string $key)Read a terminal screen by key
saveTerminalScreen(string $key, string $content)Write a terminal screen
uploadTerminalScreen(string $key, string $contentBase64, string $originalName)Upload a terminal screen
deleteTerminalScreen(string $key)Delete a terminal screen
listSixelScreens()List Sixel screen files
getSixelScreen(string $key)Read a Sixel screen by key
uploadSixelScreen(string $key, string $contentBase64, string $originalName)Upload a Sixel screen
deleteSixelScreen(string $key)Delete a Sixel screen

Content Pages

Command (client method)Description
setSystemNews(string $text)Write system news content
setHouseRules(string $text)Write house rules content
setLoginSplash(string $text)Write login splash text
setLoginAnsi(string $text)Write login ANSI art
setRegisterSplash(string $text)Write registration splash text

Logging

Command (client method)Description
getLogs(int $lines)Return the last N lines from all daemon log files
serverLog(string $level, string $message, array $context)Append an entry to server.log via the daemon
udpLog(string $logFile, string $level, string $message)Send a log line via UDP (best-effort, low overhead)

i18n Overlays

Command (client method)Description
getI18nOverlay(string $locale, string $namespace)Read the base catalog keys and any site-level override overrides for a locale/namespace
saveI18nOverlay(string $locale, string $namespace, array $overrides)Write override entries; pass empty array to clear all

License

Command (client method)Description
setLicense(array $licenseData)Write data/license.json
deleteLicense()Delete data/license.json, reverting to Community Edition

Daemon Control

Command (client method)Description
restartMrcDaemon()Stop and restart the MRC daemon
stopServices()Stop all managed services and shut down the admin daemon

File & Content Operations

Command (client method)Description
scanFile(int $fileId)Run an on-demand virus scan for a file by database ID
runEchomailRobot(int $robotId)Run an echomail robot by ID (with --debug output)
checkAutoFeed(int $feedId, bool $force = true, bool $verbose = true)Run scripts/rss_poster.php for a single auto feed and return stdout/stderr
reindexIso(int $areaId)Spawn a background re-index of an ISO-backed file area
rehatchFile(int $fileId)Re-hatch a single file via scripts/file_hatch.php

Configuration Save Pattern

Any admin setting that writes a config file follows the same flow. The web process cannot write config files directly — it delegates the write to the daemon and then triggers a reload.

Admin UI → AdminDaemon → Config-Reload flow

GET: loading settings into the admin UI

This path is specific to populating admin forms. It is not how application features read configuration at runtime — feature code reads config classes directly (see ConfigurationSystem.md).

  1. The browser requests an admin page (e.g. /admin/binkp-config).
  2. The PHP route calls the appropriate AdminDaemonClient getter (e.g. getBinkpConfig()).
  3. The daemon reads the config file and returns the current values.
  4. The route passes the values to a Twig template, which renders the form.

POST: saving settings from the admin UI

  1. The browser submits the form via an AJAX POST to an admin API endpoint.
  2. The PHP route validates and normalises the incoming data.
  3. The route calls the appropriate AdminDaemonClient setter (e.g. setBinkpConfig($data)).
  4. The daemon writes the config file and, where applicable, signals the affected daemon to reload (e.g. sending SIGHUP to binkp_server).
  5. The daemon returns the saved config, which the route echoes back as JSON.
  6. The browser displays a success confirmation.

Example

// Route handler (routes/admin-routes.php)
SimpleRouter::post('/api/binkp-config', function () {
    RouteHelper::requireAdmin();
    $data = json_decode(file_get_contents('php://input'), true);
    $client = new AdminDaemonClient();
    $result = $client->setBinkpConfig($data);
    $client->close();
    echo json_encode(['ok' => true, 'config' => $result]);
});
// AdminDaemonServer::handleCommand() — 'set_binkp_config' case
$binkpConfig = BinkpConfig::getInstance();
$binkpConfig->setBinkpConfig(/* fields from $data */);
$this->writeResponse($client, ['ok' => true, 'result' => $binkpConfig->getBinkpConfig()]);

The key invariant is that no route or controller ever calls file_put_contents() on a config file directly — the daemon is the sole writer.


Adding a New Command

  1. Add a public method to AdminDaemonClient that calls $this->sendCommand('my_command', [...]).
  2. Add a case 'my_command': branch in AdminDaemonServer::handleCommand().
  3. If the command writes a new log file, add the filename to AdminDaemonServer::UDP_ALLOWED_LOG_FILES.
  4. If the command needs to spawn a subprocess, use $this->spawnCommand([...]) for fire-and-forget or $this->runCommand([...]) to wait for output.

Logging from Web Routes

Web routes must not write log files directly. Use one of these patterns:

// Preferred — uses the Logger class which falls back to UDP automatically
getServerLogger()->info('User sent netmail', ['user_id' => $userId]);

// Alternative — one-shot static call directly to the daemon
AdminDaemonClient::log('INFO', 'Something happened', ['context' => 'value']);

See CLAUDE.md and docs/ARCHITECTURE.md for the full logging policy.