<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\atto as A;
use seekquarry\yioop\library\certificates as LC;
use seekquarry\yioop\library\storage_formats as LSF;
use seekquarry\yioop\models as M;
/**
* Used to make using Yioop through this index page rather than as part of
* a library
*/
const BASE_INDEX_ENTRY = true;
/**
* This will in turn load Config.php
*/
require_once __DIR__ . "/src/library/Utility.php";
/**
* passthruYioopRequest hands the request now being served to the copy
* of Yioop that answers it. Two index.php files take part: this one,
* which a web server reaches and which starts the server when Yioop
* runs as its own, and the one under src, which works out the reply.
* This function tidies the address first, sending the reader to the
* plain form where the one asked for holds a needless step in its
* path, and it records that addresses on this site are written the
* short way rather than as a query.
*
* @return bool always true once the inner index page has handled the
* request
*/
function passthruYioopRequest()
{
$web_site = $GLOBALS['web_site'];
$uri = $_SERVER['REQUEST_URI'];
$new_uri = preg_replace("@/(\.?/)+@", "/", $uri);
if (!empty($web_site) && $new_uri != $uri) {
$web_site->header("Location: $new_uri");
A\webExit();
}
require_once __DIR__ . "/src/index.php";
bootstrap($web_site);
return true;
}
/**
* processCommandLine reads the words typed after the script name when
* Yioop is run as its own web server, and works out from them which
* command was asked for and which address to listen on. It rewrites
* those words into the fixed places the rest of this file reads them
* from, so every later step sees one shape whatever was typed.
*/
function processCommandLine()
{
global $argv;
define("seekquarry\\yioop\\configs\\IS_OWN_WEB_SERVER", true);
/*
secure-terminal / secure-start / secure-stop drive the TLS
launcher (bind 80 and 443 with a managed certificate) instead
of the single plain port. secure-terminal runs in this
process; secure-start daemonizes by re-spawning index.php as a
'child' (see CrawlDaemon::start), which drops the secure-*
word. So the secure listen address is set to the self-naming
marker 'secure-80-443' below: CrawlDaemon forwards it to the
child, which reads it back here as argv[3] to re-enable secure
mode, and it also names the daemon (startup line reads
"secure-80-443-index") so it is clear this is the secure
server and on which ports. The secure block ignores the
address otherwise. secure-stop maps to the plain stop, which
signals the secure daemon through the same lock and stop file.
The choice is recorded in a constant the listen-construction
step reads.
*/
$secure_marker = 'secure-80-443';
$is_secure_server = (!empty($argv[1]) && in_array($argv[1],
['secure-terminal', 'secure-start', 'secure-stop',
'secure-restart'], true)) ||
(!empty($argv[1]) && $argv[1] === $secure_marker) ||
(isset($argv[3]) && $argv[3] === $secure_marker);
define("seekquarry\\yioop\\configs\\SECURE_SERVER_MARKER",
$secure_marker);
if (!empty($argv[1])) {
if ($argv[1] === 'secure-terminal') {
$argv[1] = 'terminal';
} else if ($argv[1] === 'secure-start') {
$argv[1] = 'start';
} else if ($argv[1] === 'secure-stop') {
$argv[1] = 'stop';
} else if ($argv[1] === 'secure-restart') {
$argv[1] = 'restart';
}
}
$address = 8080;
$sleep = 0;
$session_file = "";
if (empty($argv[1])) {
echo "Yioop web server commands:\n\n";
echo " php index.php start [port] " .
"start in the background on a port\n";
echo " (default 8080)\n";
echo " php index.php terminal [port] " .
"run in this terminal, not in the\n";
echo " background\n";
echo " php index.php restart [port] " .
"restart a running server, or start\n";
echo " one if none is running\n";
echo " php index.php stop " .
"stop the running server\n";
echo " php index.php status " .
"show which Yioop processes are\n";
echo " running and on which " .
"ports\n\n";
echo "Add 443 as the port to serve HTTPS on the standard " .
"secure port.\n";
echo "The secure- forms bind both 80 and 443 with a managed " .
"certificate:\n";
echo " sudo php index.php secure-start\n";
echo " sudo php index.php secure-terminal\n";
echo " sudo php index.php secure-restart\n";
echo " sudo php index.php secure-stop\n";
exit();
} else {
if ($argv[1] == 'stop' || $argv[1] == 'status' ||
$argv[1] == 'supervisor' || $argv[1] == 'worker' ||
$argv[1] == 'mailsend') {
return;
}
$possible_address = $argv[1];
$possible_sleep = 0;
if (empty($argv[2])) {
if (in_array($argv[1], ['start', 'terminal', 'child', 'restart'])) {
$possible_address = 8080;
} else {
$argv[1] = 'start';
}
} else {
$possible_sleep = $argv[2];
if (in_array($argv[1], ['start', 'terminal', 'child', 'restart'])) {
$possible_address = $argv[2];
if (empty($argv[3])) {
$possible_sleep = 0;
if ($argv[2] == 'none') {
$possible_address = 8080;
}
} else {
$possible_sleep = $argv[3];
if ($argv[2] == 'none') {
$possible_address = $argv[3];
if (empty($argv[4])) {
$possible_sleep = 0;
} else {
$possible_sleep = $argv[4];
}
if (!empty($argv[5])) {
$session_file = $argv[5];
}
} else if (!empty($argv[4])) {
$session_file = $argv[4];
}
}
} else if (!empty($argv[3])) {
if (intval($possible_sleep) > 0) {
$argv[1] = 'restart';
} else {
$argv[1] = 'start';
}
$session_file = $argv[3];
}
}
$tmp_address = intval($possible_address);
if ($tmp_address > 0 && $tmp_address < 65535) {
$address = $possible_address;
} else {
$url_parts = @parse_url($possible_address);
if(!empty($url_parts)) {
$address = $possible_address;
}
}
if (intval($possible_sleep) > 0 ) {
$sleep = $possible_sleep;
}
}
/* A bare "443" port (terminal 443 / start 443) means the user
wants HTTPS on the standard secure port. Turn on secure serving
for that one port: a self-signed certificate is written if none
exists and the port is served over TLS. This is the single-port
counterpart to secure-terminal / secure-start, which instead
bind both 80 and 443 for the full public set-up (HTTP-to-HTTPS
redirect and the ACME challenge on 80). Here only the one port
the user named is served. */
$is_single_secure_port = !$is_secure_server &&
intval($address) === 443;
define("seekquarry\\yioop\\configs\\IS_SECURE_SERVER",
$is_secure_server);
define("seekquarry\\yioop\\configs\\SECURE_SINGLE_PORT",
$is_single_secure_port);
$argv[2] = 'none';
$argv[3] = ($is_secure_server) ? $secure_marker : $address;
$argv[4] = $sleep;
$argv[5] = $session_file;
}
/**
* $web_site is the WebSite object that answers Yioop's requests, with the
* routes set up below. Run from the command line, it listens for web
* connections itself.
*/
/* When this run of the server began. A stop or a restart asked for
before this moment belongs to the server this one replaced; one asked
for after it is this server's to carry out. */
$server_began = time();
$web_site = new A\WebSite('.');
/* The server reads no settings of its own, so what the site wants of it
is handed over here. Left alone, a failure while a request is handled
is caught and only that connection is let go; asked for, it is let out
and ends the server, which is what someone tracking such a failure
down wants to see. */
$web_site->no_wrap_requests = (bool)C\NO_WRAP_REQUESTS;
$web_site->get('/*', 'seekquarry\\yioop\\passthruYioopRequest');
$web_site->head('/*', 'seekquarry\\yioop\\passthruYioopRequest');
$web_site->post('/*', 'seekquarry\\yioop\\passthruYioopRequest');
$web_site->put('/*', 'seekquarry\\yioop\\passthruYioopRequest');
$web_site->delete('/*', 'seekquarry\\yioop\\passthruYioopRequest');
$web_site->options('/*', 'seekquarry\\yioop\\passthruYioopRequest');
$web_site->propfind('/*', 'seekquarry\\yioop\\passthruYioopRequest');
$web_site->mkcol('/*', 'seekquarry\\yioop\\passthruYioopRequest');
$web_site->move('/*', 'seekquarry\\yioop\\passthruYioopRequest');
$web_site->lock('/*', 'seekquarry\\yioop\\passthruYioopRequest');
$web_site->unlock('/*', 'seekquarry\\yioop\\passthruYioopRequest');
/*
The conversation refresh can be pushed over a held-open WebSocket when
Yioop serves itself through this command-line server. The route is
registered here, at startup, so it is in place before any upgrade
request is read; its handler lives in src/index.php, loaded the same
lazy way passthruYioopRequest loads it, so the socket work sits with the
rest of the messaging code rather than in this launcher.
*/
$web_site->ws('/ws-messages', function ($ws) use ($web_site) {
require_once __DIR__ . "/src/index.php";
\seekquarry\yioop\messageWsConnect($ws, $web_site);
});
if ($web_site->isCli()) {
/* HOSTS_NAME_SERVER says this web server is the name server itself:
its machine list names it as BASE_URL. A worker then runs a request
to the name server's address inside itself, rather than sending it
back to this server and waiting on its own queue. */
if ((new M\MachineModel())->hostsNameServer()) {
define("seekquarry\\yioop\\configs\\HOSTS_NAME_SERVER", true);
}
if ($argv[1] == 'worker') {
/* Started by the web server to run requests for it: the routes
above are built as usual, then requests come from the link to
the server rather than from a port. A request brings the
server's settings, which turn on logging to files, so the log
is named index here, as the server's is: otherwise the lines
the worker logs go to message.log. This first line goes to
the worker's output, as no request has come yet. */
L\crawlLog("Web server worker " . getmypid() . " started", "index");
$web_site->serveAsWorker();
exit();
}
if ($argv[1] == 'mailsend') {
\seekquarry\yioop\library\mail\SmtpClient::runSendWorker(
$argv[2] ?? "", intval($argv[3] ?? 0));
exit();
}
if ($argv[1] == 'supervisor') {
/* This run of index.php is the one process that keeps root.
The secure start begins it once the web server is on its
way, and it binds nothing and listens to nobody. Every few
seconds it compares what should be running against what is,
and starts what is missing. That covers both things a server
which has become WWW_USER can no longer do: bring back a
mail server that wants a port below 1024, and put up a
replacement for itself after a restart. What should be
running is read from where it was already recorded, the
ACTIVE_PROCESS rows Manage Machines writes and the port file
the server writes, so nothing here needs a channel of its
own. */
L\CrawlDaemon::$name = "index";
L\CrawlDaemon::$subname = "";
L\CrawlDaemon::$mode = 'supervisor';
$_SERVER["LOG_TO_FILES"] = true;
L\crawlLog("\n\nInitialize logger..", "index");
$address = empty($argv[2]) ? "" : $argv[2];
$terminal_id = intval($argv[3] ?? 0);
$began = time();
$machine_model = new M\MachineModel();
L\crawlLog("Supervisor running as root, watching for services " .
"that should be running on " . $address);
$server_missing_passes = 0;
while (true) {
sleep(C\SUPERVISOR_SLEEP_TIME);
clearstatcache();
$stop_file = L\CrawlDaemon::GLOBAL_STOP_FILE_NAME;
if (file_exists($stop_file) &&
intval(file_get_contents($stop_file)) >= $began) {
L\crawlLog("Supervisor stopping, since everything was " .
"asked to stop");
break;
}
if ($terminal_id > 0 &&
!L\CrawlDaemon::isProcessAlive($terminal_id)) {
L\crawlLog("Supervisor stopping, since the terminal " .
"server it watched has ended");
break;
}
$running = L\CrawlDaemon::statuses();
if (empty($running["index"]) && !empty($address) &&
$terminal_id == 0) {
/* Waiting a second pass leaves room for a restart run
from the command line, which is root as well, to put
the replacement up itself. Two servers binding the
same ports would leave one of them dead. */
$server_missing_passes++;
if ($server_missing_passes > 1) {
L\crawlLog("Supervisor starting a web server on " .
$address . ", since none is running");
preg_match_all('/\d+/', $address, $ports);
foreach ($ports[0] as $port) {
foreach (L\CrawlDaemon::portHolders($port) as
$holder => $command) {
L\crawlLog("Supervisor: port $port is held " .
"by process $holder: $command");
}
}
/* A server that stopped for a restart it could
not do itself, having given up root, saved its
sessions first; the replacement is handed them,
so no one is signed out by the restart. An old
file from an earlier restart is not used. */
$session_path = C\WORK_DIRECTORY .
"/PreviousSession.txt";
$sessions_to_keep = (file_exists($session_path) &&
time() - filemtime($session_path) <
C\ONE_MINUTE * 5) ? $session_path : "";
L\CrawlDaemon::start("index", "none",
'"' . $address . '" "0" "' . $sessions_to_keep .
'"', -3);
$server_missing_passes = 0;
}
} else {
$server_missing_passes = 0;
}
foreach ($machine_model->getNameServerProcessTypes() as $type) {
if (!in_array($type, L\CrawlDaemon::RESTART_DAEMONS,
true) || !empty($running[$type])) {
continue;
}
L\crawlLog("Supervisor starting " . $type .
", which is switched on but not running");
L\CrawlDaemon::start($type, "none", "", -3);
}
}
exit();
}
if ($argv[1] == 'status') {
/* Report what Yioop processes are running and, for the web
server, which port(s) it is listening on. Reads the same
lock files the server uses to track running daemons, so the
report reflects what is actually alive right now. */
$running = L\CrawlDaemon::statuses();
$labels = ["index" => "Web server",
"MediaUpdater" => "Media Updater",
"MailServer" => "Mail Server",
"TurnServer" => "Turn Server",
"QueueServer" => "Queue Server", "Fetcher" => "Fetcher"];
if (empty($running)) {
echo "No Yioop processes appear to be running.\n";
} else {
echo "Running Yioop processes:\n";
foreach ($running as $name => $instances) {
$label = $labels[$name] ?? $name;
$line = " " . $label;
$count = count($instances);
if ($count > 1) {
$line .= " x" . $count;
}
echo $line . "\n";
}
}
exit();
}
$web_site->setTimer(10, function () use ($web_site, $server_began) {
if (!C\PROFILE) {
return;
}
$start_time = $server_began;
$global_stop_file = L\CrawlDaemon::GLOBAL_STOP_FILE_NAME;
if (file_exists($global_stop_file)) {
$stop_time = intval(file_get_contents($global_stop_file));
if ($stop_time > $start_time) {
echo "Stopping Server...";
$web_site->stop = true;
$web_site->restart = false;
/* clear this server's lock as part of honoring the
stop, so a follow-up start is not refused for
PROCESS_TIMEOUT because a request handled between
the stop command's unlink and shutdown recreated
the lock with a fresh timestamp
*/
L\CrawlDaemon::releaseLock();
} else {
unlink($global_stop_file);
}
}
$restart_request_file = C\DATA_DIR . "/RestartServer.txt";
if (file_exists($restart_request_file)) {
$request_time = intval(
file_get_contents($restart_request_file));
unlink($restart_request_file);
L\crawlLog("Restart request read: asked at $request_time, this " .
"server first ticked at $start_time");
if ($request_time > $start_time) {
/* A command-line restart asked for this. Take the
background daemons down and record them for relaunch,
then restart this server the same way the admin
Restart Server action does, so there is one restart
path to maintain. */
L\crawlLog("Restart asked for from the command line: " .
"stopping the services");
/* The moment the restart was asked for is handed on,
so that stopping the services cannot end one that
something else has started since. */
L\CrawlDaemon::saveDaemonStateForRestart($request_time);
$session_path = C\WORK_DIRECTORY .
"/PreviousSession.txt";
L\crawlLog("Restart: saving sessions to $session_path");
if (!is_writable(dirname($session_path)) ||
(file_exists($session_path) &&
!is_writable($session_path))) {
L\crawlLog("Restart: $session_path cannot be written " .
"by this process; restarting without the sessions");
} else {
$web_site->saveSessionsForRestart($session_path);
}
/* A server that has dropped to WWW_USER cannot bind
ports 80 and 443 again, so it stops without asking to
relaunch itself and leaves that to the command, which
runs as root and watches for the server to go. Asking
anyway spawned a process that could not bind and died
with its output in /dev/null. */
$can_rebind = !function_exists("posix_geteuid") ||
posix_geteuid() === 0;
if ($can_rebind) {
L\crawlLog("Restart: stopping this server to " .
"relaunch it");
$web_site->restart = "restart " . $session_path;
} else {
L\crawlLog("Restart: stopping this server; the " .
"restart command starts the new one, since this " .
"process cannot bind its ports again");
}
$web_site->stop = true;
}
}
$log_msg = "Current Memory Usage: " . memory_get_usage() .
" Peak usage:" . memory_get_peak_usage() . " " .
"index-caches indexes=" . count(L\IndexManager::$indexes) .
" urls=" . (string)count(L\IndexManager::$urls_cache) .
" info=" . (string)count(L\IndexManager::$info_cache) .
" max-posting-read=" .
LSF\IndexDocumentBundle::$max_postings_read . "B";
L\crawlLog($log_msg);
});
if (file_exists(C\LOG_DIR)) {
// don't use logging until after log directory built on first run
$web_site->middleware(function () use ($web_site)
{
$lock_file = L\CrawlDaemon::getLockFileName("index", '');
/* in first run case, lock file won't be created by outer
instance as folder not created yet; skip the recreate
while stopping so a shutdown-window request does not
resurrect a lock the stop command just removed
*/
if (empty($web_site->stop) && !file_exists($lock_file)) {
L\CrawlDaemon::writeLockFile($lock_file, time());
}
/* A request from another Yioop machine carries a session
that is the timestamp hashed with this site's key, which
a reader cannot forge. Marking it here lets the web
server leave the fetchers and the archive sync at a whole
turn of its write loop, while the files going to one
block of reader IP addresses divide a turn between
them. */
if (!empty($_REQUEST['session']) &&
!empty($_REQUEST['time'])) {
$_SERVER['MACHINE_REQUEST'] = true;
}
$_SERVER['REQUEST_ARRIVED'] = microtime(true);
});
$web_site->logResponses(function ($status, $size) use ($web_site) {
if (($_SERVER['REMOTE_ADDR'] ?? "") === "0.0.0.0") {
return;
}
$took = "";
$arrived = $_SERVER['REQUEST_ARRIVED'] ??
$_SERVER['REQUEST_TIME_FLOAT'] ?? 0;
if (!empty($arrived)) {
$milliseconds = round((microtime(true) - $arrived) *
C\MILLISECONDS_PER_SECOND);
$mark = C\REQUEST_SLOWEST_MARK;
foreach (C\REQUEST_SPEED_MARKS as $under => $word) {
if ($milliseconds < $under) {
$mark = $word;
break;
}
}
$took = " " . $milliseconds . "ms" . $mark;
}
$who = empty($_SESSION['USER_NAME']) ? "" :
" " . $_SESSION['USER_NAME'];
$fibers = ($web_site->cooperative_scheduler === null) ? 0 :
count($web_site->cooperative_scheduler->tasks);
$ran_in = isset($_SERVER['ATTO_WORKER']) ?
" worker=" . ($_SERVER['ATTO_WORKER'] + 1) : "";
$access_line = $_SERVER['REMOTE_ADDR'] . $who . " " .
$_SERVER['REQUEST_URI'] . " " .
$web_site->requestProtocol() . ":" .
($_SERVER['SERVER_PORT'] ?? "") . " " . $status . " " .
$size . "B" . $took . " fibers=" . $fibers . $ran_in;
L\crawlLog($access_line);
});
register_shutdown_function(function () {
/* If the always-on server dies on a fatal error, a memory
exhaustion most of all, record which request was in flight
and the peak memory reached. A request that exhausts memory
while still building its reply never reaches the response
logger above, so without this the crashing request can only
be guessed at from the surrounding arrival lines. */
$fatal = error_get_last();
if ($fatal === null || !in_array($fatal['type'],
[E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
return;
}
$remote = $_SERVER['REMOTE_ADDR'] ?? "?";
$uri = $_SERVER['REQUEST_URI'] ?? "?";
L\crawlLog("Fatal while serving " . $remote . " " . $uri .
" peak_memory=" . memory_get_peak_usage() . " " .
$fatal['message']);
});
}
$session_info = null;
if (!empty($argv[5]) && file_exists($argv[5])) {
$session_info = unserialize(file_get_contents($argv[5]));
if ($argv[1] == 'restart') {
if(!empty($session_info['SESSIONS']['TERMINAL'])) {
$argv[1] = 'start';
} else {
$argv[1] = 'child';
}
}
} else if ($argv[1] == 'restart') {
/* Command-line restart. If a server is already running, leave
the work to it: drop a request file that it picks up on its
next tick and then restarts itself the same way the admin
Restart Server action does (stopping and relaunching the
background daemons, handing sessions across so people stay
signed in). Talking to it through a file rather than a web
request avoids opening an unauthenticated restart endpoint.
If nothing is running, say so and start a fresh server. */
$index_lock = L\CrawlDaemon::getLockFileName("index", '');
$index_lock_alt = L\CrawlDaemon::getLockFileName("index", '0');
$running = false;
foreach ([$index_lock, $index_lock_alt] as $lock) {
if (L\CrawlDaemon::lockHeldByLiveProcess($lock)) {
$running = true;
break;
}
}
if ($running) {
$restart_request_file = C\DATA_DIR . "/RestartServer.txt";
file_put_contents($restart_request_file, time());
chmod($restart_request_file, 0777);
echo "Asked the running server to restart.\n";
/* Follow along until the restart finishes. First wait for
the running server to notice and remove the request file
(it polls on a timer, so this can take a few seconds);
that confirms the restart is under way. Then print each
restart phase the server records (stopping services,
waiting for and restarting the mail server, and so on)
as it changes, and finish once the phase has settled
back to empty, which the server does when it is serving
again. A generous overall cap stops the wait if no
progress is seen. */
$last_phase = "";
$waited = 0;
$max_wait = 120;
$picked_up = false;
$clear_streak = 0;
$done = false;
$started_here = false;
$index_lock = L\CrawlDaemon::getLockFileName("index", "");
$old_process_id = L\CrawlDaemon::lockProcessId($index_lock);
$relaunched = [];
$daemon_labels = ["MediaUpdater" => "Media Updater",
"MailServer" => "Mail Server",
"TurnServer" => "Turn Server"];
while ($waited < $max_wait) {
sleep(1);
$waited++;
clearstatcache(true, $restart_request_file);
if (!$picked_up) {
if (!file_exists($restart_request_file)) {
$picked_up = true;
echo " Restart under way...\n";
}
continue;
}
/* The server records which background services it is
bringing back, and it writes that list after it has
taken the request file away. Reading the list only at
the moment the request file went therefore usually
read nothing, and the services came back without
being named. So it is looked for on every pass until
it is found, since the fresh server takes it away
again once it has relaunched them. */
$marker = C\DATA_DIR . "/RestartDaemons.txt";
clearstatcache(true, $marker);
if (empty($relaunched) && file_exists($marker)) {
foreach (array_filter(array_map('trim',
explode("\n", (string)file_get_contents(
$marker)))) as $entry) {
$name = explode(" ", $entry)[0];
$relaunched[] = $daemon_labels[$name] ?? $name;
}
}
$phase = L\CrawlDaemon::getRestartStatus();
if ($phase !== "") {
$clear_streak = 0;
if ($phase !== $last_phase) {
echo " " . $phase . "\n";
$last_phase = $phase;
}
} else {
$clear_streak++;
}
/* a couple of clear readings in a row with a server
other than the old one holding the lock means the
new server is up and serving */
clearstatcache(true, $index_lock);
$new_process_id = L\CrawlDaemon::lockProcessId($index_lock);
$held = L\CrawlDaemon::lockHeldByLiveProcess($index_lock);
if ($clear_streak >= 3 && $held &&
$new_process_id !== false &&
$new_process_id != $old_process_id) {
$done = true;
break;
}
/* The old server has gone and nothing replaced it. A
server that dropped to WWW_USER cannot bind ports 80
and 443 again, so this command, which has the rights
to, starts the new one. This is read whatever the
phase file says: that file is written by the server
that has just stopped, so it can be left holding an
old phase with nothing running to clear it. */
if ($picked_up && !$held && !$started_here) {
echo " Starting the web server back up...\n";
L\CrawlDaemon::setRestartStatus("");
/* Started from here rather than through init, so
that this command keeps running and can say what
came back. init's start ends the process that
calls it, which left the reader with the daemon's
own startup lines as the last word and no list of
what had been brought back. */
$started_here = true;
$restart_options = "";
for ($word = 3; $word < count($argv); $word++) {
$restart_options .= '"' . $argv[$word] . '" ';
}
L\CrawlDaemon::start("index", "none",
trim($restart_options), -3);
}
}
if ($done) {
echo "Restart complete; the web server is back up.\n";
if (!empty($relaunched)) {
/* Each service is looked at once, as things stand
when the server is back. The list read earlier
says which were asked for, not which came back,
so naming them all would name one that never
started. Nothing is waited for here: a service
that takes a moment longer than the server is
said to be starting, and the reader gets their
prompt back at once. */
$came_back = [];
$starting = [];
$did_not_start = [];
foreach ($relaunched as $service) {
$daemon = array_search($service, $daemon_labels);
if ($daemon === false) {
$daemon = $service;
}
$state = L\CrawlDaemon::daemonState($daemon);
if ($state == L\CrawlDaemon::DAEMON_RUNNING) {
$came_back[] = $service;
} else if ($state ==
L\CrawlDaemon::DAEMON_STARTING) {
$starting[] = $service;
} else {
$did_not_start[] = $service;
}
}
if (!empty($came_back)) {
echo "These background services are running " .
"again: " . implode(", ", $came_back) .
".\n";
}
if (!empty($starting)) {
echo "These are starting up: " .
implode(", ", $starting) . ".\n";
}
foreach ($did_not_start as $service) {
/* A reader who has just restarted their site
wants to know what to do next, so the name of
the file to look in is written out rather
than left to be worked out. */
$daemon = array_search($service, $daemon_labels);
if ($daemon === false) {
$daemon = $service;
}
echo "The " . $service . " did not come back " .
"up.\n";
echo " See what is running with: php " .
"index.php status\n";
echo " The reason should be at the end of " .
"this file:\n";
echo " " . C\LOG_DIR . "/" . $daemon .
".log\n";
}
}
} else if (!$picked_up) {
echo "The running server did not pick up the restart " .
"request; check the server log.\n";
} else {
echo "Restart did not confirm within " . $max_wait .
" seconds; check the server log.\n";
}
exit();
}
echo "No server appears to be running; starting a fresh one.\n";
$argv[1] = 'start';
}
if ($argv[1] == 'start' || $argv[1] == 'terminal') {
/* Yioop runs a single index web server. If one is already up, a
second start or terminal run (even on a different port) cannot
bind alongside it, so say so clearly and point at the right
next step rather than leaving the impression that two servers
on two ports are now running. A lock left behind by a server
that crashed or was stopped with Ctrl-C is recognized as stale
through its recorded process id and removed, so the next start
or terminal run is not blocked and needs no separate stop
first. */
$index_lock = L\CrawlDaemon::getLockFileName("index", '');
$index_lock_alt = L\CrawlDaemon::getLockFileName("index", '0');
foreach ([$index_lock, $index_lock_alt] as $lock) {
if (!file_exists($lock)) {
continue;
}
if (L\CrawlDaemon::lockHeldByLiveProcess($lock)) {
echo "An index web server is already running. " .
"Yioop runs one index web server " .
"at a time, so it cannot also start on " .
"another port.\n";
/* A reader who typed secure-start is told about
secure-stop and secure-restart, since the plain
commands serve a different pair of ports. */
$kind = (C\nsdefined("IS_SECURE_SERVER") &&
C\IS_SECURE_SERVER) ? "secure-" : "";
echo "To move it to a different port, stop it " .
"first (php index.php " . $kind . "stop), then " .
"start on the port you want. To restart it in " .
"place, use php index.php " . $kind .
"restart.\n";
exit();
}
unlink($lock);
}
}
if ($argv[1] == 'start') {
/* The address the started server answers on, which the start
connects to before it says whether the server came up. A
secure server answers on 443; a server bound to every
interface is reached on this machine's loopback address. */
$check_address = ((C\nsdefined("IS_SECURE_SERVER") &&
C\IS_SECURE_SERVER) || (C\nsdefined("SECURE_SINGLE_PORT") &&
C\SECURE_SINGLE_PORT)) ? "443" : (string)($argv[3] ?? "");
if (ctype_digit($check_address)) {
$check_address = "127.0.0.1:$check_address";
} else {
$address_parts = @parse_url($check_address) ?: [];
$check_host = trim($address_parts['host'] ?? "", "[]");
if (in_array($check_host, ["", "0.0.0.0", "::"])) {
$check_host = "127.0.0.1";
} else if (str_contains($check_host, ":")) {
$check_host = "[$check_host]";
}
$check_address = empty($address_parts['port']) ? "" :
$check_host . ":" . $address_parts['port'];
}
L\CrawlDaemon::$index_address = $check_address;
if ($check_address != "" &&
L\CrawlDaemon::indexServerUp($check_address, 0)) {
/* No index server runs, as its lock said above, so another
program holds the address and the server could not listen
on it. Nothing is started. */
echo "Another program already answers on $check_address, " .
"so the web server cannot listen there.\n";
exit();
}
}
if (in_array($argv[1], ['start', 'terminal']) && !empty(C\WWW_USER) &&
function_exists("posix_geteuid") && posix_geteuid() === 0) {
/* Start the one process that keeps root, before this one
spawns the server and exits. It is what starts a mail
server or a replacement web server later, since neither
can bind its ports once the server has become WWW_USER.
Where there is no user to become, or where this process is
not root, nothing here is needed and none is started. */
/* A terminal server runs in this process, so the supervisor is
given its id: it watches that process rather than starting a
web server of its own, and stops once that process ends. */
L\CrawlDaemon::execScriptInOwnProcess("'" . C\PARENT_DIR .
"/index.php'", "supervisor '" . $argv[3] . "'" .
(($argv[1] == 'terminal') ? " " . getmypid() : ""));
}
$launcher_lock = L\CrawlDaemon::getLockFileName("Launcher", "");
if (in_array($argv[1], ['start', 'terminal']) &&
!L\CrawlDaemon::lockHeldByLiveProcess($launcher_lock)) {
/* The Launcher starts the programs the web server is asked to
start, such as a fetcher that stopped. Started here, before
the server takes connections, it holds none of them, so
neither do the programs it starts. */
L\CrawlDaemon::start("Launcher", "none", "", -1);
}
if ($argv[1] == 'stop') {
(new M\MachineModel())->clearNameServerProcesses();
if (L\CrawlDaemon::lockHeldByLiveProcess($launcher_lock)) {
L\CrawlDaemon::stop("Launcher", "", false);
}
}
L\CrawlDaemon::init($argv, "index");
L\crawlLog("\n\nInitialize logger..", "index", true);
/* Write this server's lock now, at startup. The per-request
middleware also writes it, but a server that has just
(re)started in the background as a daemon may sit with no
inbound request for several seconds; the periodic crawlLog
in the stop-watch timer calls CrawlDaemon::processHandler,
which exits a daemon whose lock file is missing. Creating the
lock here keeps the freshly restarted server alive until the
first request arrives. */
if (file_exists(C\SCHEDULES_DIR)) {
$index_lock_file = L\CrawlDaemon::getLockFileName("index", '');
if (!file_exists($index_lock_file)) {
L\CrawlDaemon::writeLockFile($index_lock_file, time());
L\giveFileToWebUser($index_lock_file);
}
}
if ($argv[4] > 0) {
sleep($argv[4]);
}
if (empty($session_info)) {
$session_info = ['SESSIONS' => []];
}
if ($argv[1] == 'terminal') {
$session_info['SESSIONS']['TERMINAL'] = true;
}
$context = [];
/* Let the server accept a request body as large as PHP itself is
configured to accept, rather than capping it at the atto
default (10MB). Without this, a file upload bigger than 10MB —
well within a typical PHP post_max_size — is refused before it
reaches Yioop and the browser shows a bare 400 Bad Request.
PHP's post_max_size is read and converted to a byte count; a
value of 0 means "no limit" in PHP, and any value below the
atto default is raised to it so a small or unset ini setting
cannot shrink what the server will take. */
$php_post_max = L\metricToInt(ini_get('post_max_size'));
/* The floor on how big a request body may be comes from the web
server's own defaults rather than being repeated here, so the two
cannot drift apart. */
$atto_defaults = A\WebSite::defaultServerGlobals();
$atto_default_max_request = $atto_defaults['MAX_REQUEST_LEN'];
if ($php_post_max <= 0) {
$context['MAX_REQUEST_LEN'] = PHP_INT_MAX;
} else if ($php_post_max < $atto_default_max_request) {
$context['MAX_REQUEST_LEN'] = $atto_default_max_request;
} else {
$context['MAX_REQUEST_LEN'] = $php_post_max;
}
if (C\nsdefined("SERVER_CONTEXT")) {
$context['SERVER_CONTEXT'] = C\SERVER_CONTEXT;
}
/* The names this server answers to, so the WebSite can set
$_SERVER['SERVER_NAME'] per request to the domain a visitor
actually used (validated against this list) rather than the
single name it bound to. Outbound mail links read SERVER_NAME,
so a visitor on one of several served domains gets links back
to that same domain. Passed as a plain list to keep the atto
WebSite free of Yioop config; an empty list leaves the
per-request naming off. */
$configured_domains = trim((string)C\SECURE_DOMAINS);
if ($configured_domains !== "") {
$context['SECURE_DOMAINS'] = array_values(array_filter(
array_map('trim', preg_split('/[\s,]+/',
$configured_domains))));
}
/* How often (in whole seconds) the running web server should write
a memory-usage line to the error log, or 0 to leave it off. It
is a switchable diagnostic for tracking slow memory growth in the
always-on server. Passed as a plain server value so the atto
WebSite stays free of Yioop config; the WebSite takes it back out
of its globals so it does not leak into $_SERVER. */
$context['WEBSITE_MEMORY_SAMPLE_PERIOD'] =
(int)C\WEBSITE_MEMORY_SAMPLE_PERIOD;
$context['SESSION_INFO'] = $session_info;
if ((int)C\WEB_SERVER_WORKERS > 0) {
$context['WORKERS'] = (int)C\WEB_SERVER_WORKERS;
/* The crawl's own requests, from fetchers and queue servers, have
the last worker to themselves, so a crawl keeps moving while the
other workers answer searches. */
$context['WORKER_RESERVED'] = function () {
return ($_REQUEST['c'] ?? '') === 'fetch';
};
$context['WORKER_STOP'] = function ($process_id, $ask_first) {
L\CrawlDaemon::stopProcess($process_id, $ask_first);
};
$context['WORKER_START'] = function () {
L\CrawlDaemon::execScriptInOwnProcess("'" . C\PARENT_DIR .
"/index.php'", "worker");
};
$context['WORKER_LOG'] = C\LOG_DIR . "/index.log";
/* Streamed replies are sent from the web server as they are
made, so the requests that stream them stay there, as
WebSocket connections do: resources such as video, whether
asked for by c=resource or by a /wd/ path, anything asked for
by byte range, and mail attachment downloads. */
$context['FRONT_ONLY'] = function () {
/* A request from no session for a target already hot is
refused here, by bootstrap's refuseHotTarget, rather than
queued for a worker only to be refused there. */
$key = empty($_COOKIE[C\p('SESSION_NAME')]) ?
L\HotTargets::keyFromRequest($_SERVER['REQUEST_URI'] ?? "",
$_REQUEST) : "";
if ($key !== "" && L\HotTargets::isHot($key, time())) {
return true;
}
return ($_REQUEST['c'] ?? '') === 'resource' ||
str_contains($_SERVER['REQUEST_URI'] ?? '', '/wd/') ||
!empty($_SERVER['HTTP_RANGE']) ||
in_array($_REQUEST['arg'] ?? '', ['downloadAttachment',
'downloadAllAttachments']);
};
$context['FILE_JOURNAL'] = C\DATA_DIR . "/WebServerFileJournal.txt";
file_put_contents($context['FILE_JOURNAL'], "");
/* Made while the server is still root; the workers append to it
as the run-as user. */
L\giveFileToWebUser($context['FILE_JOURNAL']);
}
/* On a restart the WebSite re-spawns itself; for the secure
server it must come back binding 80 and 443, which the
launcher selects from this marker as argv[1] rather than the
single parsed address. Passing it as a server global lets the
atto WebSite reuse it without knowing anything Yioop-specific
about secure mode. */
if (C\IS_SECURE_SERVER) {
$context['RESTART_ADDRESS'] = C\SECURE_SERVER_MARKER;
}
$listen_address = $argv[3];
if (C\IS_SECURE_SERVER || C\SECURE_SINGLE_PORT) {
/*
Secure launcher: bind 80 and 443 from this one process
rather than the single port the plain modes use. Port
443 carries the managed certificate from SECURITY_DIR
(not SERVER_CONTEXT); port 80 is plain HTTP, used to
answer the ACME HTTP-01 challenge and otherwise redirect
to HTTPS. The privileged binds need root; WWW_USER (when
set) is the user the server drops to afterward, so the
long-running process does not hold root. Cert reload on
renewal updates the live listener context in place and
needs no rebind, so the drop does not block renewal.
*/
unset($context['SERVER_CONTEXT']);
$cert_file = C\SECURE_CERT_FILE;
$key_file = C\SECURE_KEY_FILE;
$has_cert = file_exists($cert_file) && file_exists($key_file);
if (!$has_cert) {
/* No managed certificate yet (for example a fresh
install). Generate a self-signed bootstrap certificate
so port 443 binds with a working certificate rather
than a missing one, which would accept connections and
then fail every TLS handshake. The server runs (with a
browser trust warning); when ACME is on, the HTTP-01
challenge on port 80 then replaces it with a real
certificate. Covers the configured secure domains so
the placeholder at least names the right hosts. */
$secure_domains = C\SECURE_DOMAINS;
$boot_domains = ($secure_domains === '') ? [] :
array_filter(array_map('trim',
explode(',', $secure_domains)));
$self_signed = LC\CsrBuilder::selfSigned($boot_domains);
if ($self_signed !== null) {
$installer = new LC\CertInstaller($cert_file,
$key_file);
$has_cert = $installer->writeFiles(
$self_signed['cert_pem'],
$self_signed['key_pem']);
}
if ($has_cert) {
L\crawlLog("Secure server: no managed certificate " .
"found; wrote a self-signed bootstrap " .
"certificate so 443 can bind. Browsers will warn " .
"until a real certificate is installed" .
(C\ACME_ON ? " (ACME will obtain one)." : "."));
} else {
L\crawlLog("Secure server: no managed certificate " .
"and could not write a self-signed bootstrap " .
"certificate; TLS on 443 will fail until a " .
"certificate is installed.");
}
}
$alpn = C\ALPN_PROTOCOLS;
$alpn_names = ($alpn === '') ? [] :
array_filter(array_map('trim', explode(',', $alpn)));
$ssl_context = [
'local_cert' => $cert_file,
'local_pk' => $key_file,
'allow_self_signed' => true,
'verify_peer' => false,
'alpn_protocols' => $alpn,
];
if (C\SECURE_SINGLE_PORT) {
/* single secure port: serve TLS on 443 only, with no
port-80 companion, since the user asked for one port. */
$listen_address = [
['address' => 'tcp://0.0.0.0:443',
'context' => ['ssl' => $ssl_context]],
];
} else {
$listen_address = [
['address' => 'tcp://0.0.0.0:443',
'context' => ['ssl' => $ssl_context]],
['address' => 'tcp://0.0.0.0:80'],
];
}
/* When h3 is among the selected protocols, add a QUIC
listener on UDP 443 carrying the same managed certificate.
It coexists with the TCP 443 listener: HTTP/2 and
HTTP/1.1 over TCP, HTTP/3 over QUIC, all advertised in the
TCP listener's ALPN list. The H3 listener opens only if
the optional QUIC pieces are present; otherwise it falls
through and the server keeps serving over TCP. */
$h3_on = in_array('h3', $alpn_names);
if ($h3_on) {
$listen_address[] = [
'address' => 'tcp://0.0.0.0:443',
'context' => ['ssl' => $ssl_context],
'protocol' => 'h3',
];
}
if (!empty(C\WWW_USER)) {
$context['USER'] = C\WWW_USER;
/* The server drops to this user after binding its ports.
PHP reads the certificate and key at the first handshake,
after the drop, and the server goes on writing its lock,
origin and messages files under schedules and its log
files under log, some of which this process has already
written as root. All of those are given to that user now,
while this process can still do it: the security folder
with its two files, and the schedules and log folders with
every plain file directly inside each. */
$www_user = function_exists("posix_getpwnam") ?
posix_getpwnam(C\WWW_USER) : false;
/* Only a process running as root can give a file away, and
only root can bind ports 80 and 443. A relaunch started by
a server that had already dropped to WWW_USER is neither,
and its chown calls raised warnings that Yioop's error
handler turned into output before the bind was even
tried. */
$as_root = function_exists("posix_geteuid") &&
posix_geteuid() === 0;
if ($as_root && !empty($www_user['uid'])) {
$owned = [C\SECURITY_DIR, C\SCHEDULES_DIR, C\LOG_DIR,
C\WORK_DIRECTORY . "/PreviousSession.txt",
C\DATA_DIR . "/RestartStatus.txt",
C\DATA_DIR . "/RestartDaemons.txt"];
foreach ([C\SCHEDULES_DIR, C\LOG_DIR] as $folder) {
foreach (glob("$folder/*") ?: [] as $entry) {
if (is_file($entry)) {
$owned[] = $entry;
}
}
}
if ($has_cert) {
$owned[] = $cert_file;
$owned[] = $key_file;
}
foreach ($owned as $entry) {
/* A path in this list need not be there yet: the
restart files are written only by a restart. The
@ does not keep Yioop's error handler quiet, and
what it prints lands in front of the page body
and stops the server answering at all. */
if (file_exists($entry)) {
chown($entry, $www_user['uid']);
}
}
clearstatcache();
foreach (($has_cert ? [$cert_file, $key_file] : []) as
$needed) {
if (fileowner($needed) !== $www_user['uid']) {
echo "Could not give $needed to " . C\WWW_USER .
"; the secure port will refuse every " .
"connection until that user can read it.\n";
}
}
}
}
if (!C\ACME_ON) {
L\crawlLog("Secure server: ACME is OFF (ACME_ON is " .
"false); certificates will not be obtained or renewed " .
"automatically.");
} else {
L\crawlLog("Secure server: ACME is ON. Certificate " .
"renewal runs as a media job, so MediaUpdater must be " .
"running for certificates to update.");
}
if ($h3_on) {
L\crawlLog("Secure server: HTTP/3 selected; opening a " .
"QUIC listener on UDP 443. If the optional QUIC " .
"extensions are missing the server keeps serving " .
"over TCP (HTTP/2 and HTTP/1.1).");
}
}
$gc_window = max(intval(C\AUTOLOGOUT),
intval(ini_get('session.gc_maxlifetime')));
ini_set('session.gc_maxlifetime', (string)$gc_window);
if (C\IS_SECURE_SERVER || C\SECURE_SINGLE_PORT) {
/*
Watch the managed certificate file and refresh the live
TLS listener context whenever it changes, so a renewal
written by the AcmeRenew media job (which runs in the
MediaUpdater process, not this one) is served without a
restart. The once-a-minute stat is cheap; new handshakes
pick up the refreshed certificate while in-flight
connections finish on the one they started with.
*/
$secure_cert_file = C\SECURE_CERT_FILE;
$secure_key_file = C\SECURE_KEY_FILE;
$last_cert_mtime = file_exists($secure_cert_file)
? filemtime($secure_cert_file) : 0;
$web_site->setTimer(C\ONE_MINUTE,
function () use ($web_site, $secure_cert_file,
$secure_key_file, &$last_cert_mtime) {
clearstatcache(true, $secure_cert_file);
if (!file_exists($secure_cert_file)) {
return;
}
$mtime = filemtime($secure_cert_file);
if ($mtime <= $last_cert_mtime) {
return;
}
$last_cert_mtime = $mtime;
$web_site->reloadSecureCertificate($secure_cert_file,
$secure_key_file);
L\crawlLog("Secure server: certificate file changed; " .
"refreshed the live TLS listener context. New " .
"connections use the renewed certificate.");
});
}
$restart_daemons_marker = C\DATA_DIR . "/RestartDaemons.txt";
if (file_exists($restart_daemons_marker)) {
/* a Restart Server action or profile change stopped the
background daemons (MediaUpdater, MailServer, TurnServer)
that had
been started through Manage Machines and recorded them
here; wait for each to finish exiting then relaunch it.
The waiting is done across repeating-timer ticks rather
than in one blocking loop so the freshly bound web server
keeps serving (and answering the restart-status poll)
while a daemon that is mid-download takes its time to
stop. The marker is removed up front so this fires once. */
$wanted = array_filter(array_map('trim',
explode("\n", (string) file_get_contents(
$restart_daemons_marker))));
unlink($restart_daemons_marker);
$relaunch_state = ["index" => 0, "waited" => 0, "id" => null];
$relaunch_labels = ["MediaUpdater" => "Media Updater",
"MailServer" => "Mail Server", "TurnServer" => "Turn Server"];
$relaunch_timer = $web_site->setTimer(1,
function () use ($web_site, $wanted, $relaunch_labels,
&$relaunch_state) {
while ($relaunch_state["index"] < count($wanted)) {
$entry_parts = explode(" ",
$wanted[$relaunch_state["index"]]);
$daemon = $entry_parts[0];
$old_process_id = isset($entry_parts[1]) ?
intval($entry_parts[1]) : 0;
if (!in_array($daemon,
L\CrawlDaemon::RESTART_DAEMONS, true)) {
$relaunch_state["index"]++;
$relaunch_state["waited"] = 0;
continue;
}
$daemon_label = $relaunch_labels[$daemon] ?? $daemon;
$step = L\CrawlDaemon::drainDaemonStep($daemon,
$old_process_id, $relaunch_state["waited"]);
if ($step == "waiting") {
L\CrawlDaemon::setRestartStatus(
"Waiting for " . $daemon_label .
" to stop...");
$relaunch_state["waited"]++;
return;
}
$statuses = L\CrawlDaemon::statuses();
if (empty($statuses[$daemon])) {
L\CrawlDaemon::setRestartStatus(
"Starting " . $daemon_label . "...");
/* -3 asks start not to look at the lock. This
runs inside the web server, and a start that
finds a lock still held ends the process that
called it, which here is the server itself:
that is what left a restart with no server and
no services. The daemon has been drained just
above, so the lock is stale whatever it says. */
L\CrawlDaemon::start($daemon, "none", "", -3);
}
$relaunch_state["index"]++;
$relaunch_state["waited"] = 0;
}
L\CrawlDaemon::setRestartStatus("");
if ($relaunch_state["id"] !== null) {
$web_site->clearTimer($relaunch_state["id"]);
}
});
$relaunch_state["id"] = $relaunch_timer;
}
$web_site->listen($listen_address, $context);
} else {
$web_site->process();
}