#!/usr/bin/env php
<?php
/**
* Gemini Capsule Server Daemon
*
* Serves user Gemini capsules over TLS on port 1965 (configurable).
*
* Routes:
* gemini://host/ BBS home ? directory of users with published capsules
* gemini://host/home/{username}/ User capsule index (index.gmi or auto-listing)
* gemini://host/home/{username}/{file} Specific published capsule file
* gemini://host/echomail/ List of public echo areas
* gemini://host/echomail/{TAG}@{domain}/ 50 most recent messages in an echo area
* gemini://host/echomail/{TAG}@{domain}/{id} Single echomail message
* gemini://host/files/ List of Gemini-public file areas
* gemini://host/files/{TAG}/ Directory listing for a file area
* gemini://host/files/{TAG}/{filename} Download a file (binary or text)
*
* Usage:
* php scripts/gemini_daemon.php [options]
*
* Options:
* --host=ADDR Bind address (default: GEMINI_BIND_HOST env or 0.0.0.0)
* --port=PORT Bind port (default: GEMINI_PORT env or 1965)
* --daemon Run as background daemon
* --pid-file=FILE Write PID to file (default: data/run/gemini_daemon.pid)
* --log-file=FILE Log file path (default: data/logs/gemini_daemon.log)
* --help Show this help
*/
chdir(__DIR__ . '/../');
require_once __DIR__ . '/../vendor/autoload.php';
use BinktermPHP\Config;
use BinktermPHP\Database;
use BinktermPHP\AppearanceConfig;
// ?? Argument parsing ??????????????????????????????????????????????????????????
/**
* Parse --key=value and --flag style CLI arguments.
*/
function parseArgs(array $argv): array
{
$args = [];
foreach ($argv as $arg) {
if (strpos($arg, '--') === 0) {
if (strpos($arg, '=') !== false) {
[$key, $value] = explode('=', substr($arg, 2), 2);
$args[$key] = $value;
} else {
$args[substr($arg, 2)] = true;
}
}
}
return $args;
}
$args = parseArgs($argv);
if (!empty($args['help'])) {
echo "Usage: php scripts/gemini_daemon.php [options]\n";
echo " --host=ADDR Bind address (default: 0.0.0.0)\n";
echo " --port=PORT Listen port (default: 1965)\n";
echo " --daemon Run as background daemon\n";
echo " --pid-file=FILE PID file path (default: data/run/gemini_daemon.pid)\n";
echo " --log-file=FILE Log file path (default: data/logs/gemini_daemon.log)\n";
echo " --help Show this help\n";
exit(0);
}
$host = $args['host'] ?? Config::env('GEMINI_BIND_HOST', '0.0.0.0');
$port = (int)($args['port'] ?? Config::env('GEMINI_PORT', '1965'));
$daemonMode = !empty($args['daemon']);
$pidFile = $args['pid-file'] ?? __DIR__ . '/../data/run/gemini_daemon.pid';
$logFile = $args['log-file'] ?? __DIR__ . '/../data/logs/gemini_daemon.log';
$certDir = __DIR__ . '/../data/gemini';
$certPath = Config::env('GEMINI_CERT_PATH', $certDir . '/server.crt');
$keyPath = Config::env('GEMINI_KEY_PATH', $certDir . '/server.key');
$externalCert = Config::env('GEMINI_CERT_PATH', '') !== '';
// ?? Logging ???????????????????????????????????????????????????????????????????
function logMsg(string $level, string $message, string $logFile): void
{
$line = '[' . date('Y-m-d H:i:s') . '] [' . getmypid() . '] [' . $level . '] ' . $message . "\n";
file_put_contents($logFile, $line, FILE_APPEND | LOCK_EX);
if (!defined('DAEMON_MODE')) {
echo $line;
}
}
// ?? TLS certificate ???????????????????????????????????????????????????????????
/**
* Generate a self-signed TLS certificate if one doesn't already exist.
* Gemini uses Trust-On-First-Use (TOFU), so self-signed certs are standard.
*
* Tries the openssl CLI first (reliable on Windows + OpenSSL 3.x), then falls
* back to PHP's openssl_* functions.
*/
function generateSelfSignedCert(string $certDir, string $certPath, string $keyPath, string $logFile): void
{
if (file_exists($certPath) && file_exists($keyPath)) {
return;
}
if (!is_dir($certDir)) {
mkdir($certDir, 0750, true);
}
logMsg('INFO', 'Generating self-signed TLS certificate...', $logFile);
$cn = 'localhost';
try {
$siteUrl = Config::getSiteUrl();
$parsed = parse_url($siteUrl);
if (!empty($parsed['host'])) {
$cn = $parsed['host'];
}
} catch (\Exception $e) {
// fall back to localhost
}
$opensslCnf = realpath(__DIR__ . '/../config/gemini_openssl.cnf');
// ?? Attempt 1: openssl CLI ????????????????????????????????????????????????
// PHP's openssl_pkey_export() produces PKCS#8 keys that OpenSSL 3.x's file
// decoder sometimes rejects with "DECODER routines::unsupported".
// The CLI uses the full provider stack and generates files it can load back.
if ($opensslCnf !== false && generateCertViaCli($certDir, $certPath, $keyPath, $cn, $opensslCnf, $logFile)) {
return;
}
// ?? Attempt 2: PHP openssl_* functions ????????????????????????????????????
$opensslCfg = $opensslCnf ? ['config' => $opensslCnf] : [];
$pkey = openssl_pkey_new(array_merge([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
], $opensslCfg));
if ($pkey === false) {
throw new \RuntimeException('openssl_pkey_new() failed ? check that the openssl PHP extension is enabled');
}
$csr = openssl_csr_new(
['commonName' => $cn],
$pkey,
array_merge(['digest_alg' => 'sha256'], $opensslCfg)
);
if ($csr === false) {
throw new \RuntimeException('openssl_csr_new() failed');
}
$cert = openssl_csr_sign($csr, null, $pkey, 3650, array_merge(['digest_alg' => 'sha256'], $opensslCfg));
if ($cert === false) {
throw new \RuntimeException('openssl_csr_sign() failed');
}
$certPem = '';
$keyPem = '';
openssl_x509_export($cert, $certPem);
openssl_pkey_export($pkey, $keyPem);
// Combined PEM (cert + key): PHP ssl:// context loads both from one file
file_put_contents($certPath, $certPem . $keyPem);
file_put_contents($keyPath, $keyPem);
chmod($certPath, 0600);
chmod($keyPath, 0600);
logMsg('INFO', "Certificate generated for CN={$cn} (PHP), stored in {$certDir}/", $logFile);
}
/**
* Generate a self-signed cert+key using the openssl command-line tool.
* Returns true on success, false if the CLI is unavailable or fails.
*/
function generateCertViaCli(
string $certDir,
string $certPath,
string $keyPath,
string $cn,
string $opensslCnf,
string $logFile
): bool {
// On Windows, the -subj value needs "//" prefix to avoid being parsed as a flag
$subj = (PHP_OS_FAMILY === 'Windows') ? '//CN=' . $cn : '/CN=' . $cn;
// Build Subject Alternative Name ? required by modern Gemini clients (e.g. Lagrange).
// CN alone is not sufficient; clients validate against the SAN extension.
$sanParts = [];
if (filter_var($cn, FILTER_VALIDATE_IP)) {
$sanParts[] = 'IP:' . $cn;
} else {
$sanParts[] = 'DNS:' . $cn;
}
// Always include localhost/127.0.0.1 so local dev tools work without cert errors
if ($cn !== 'localhost') {
$sanParts[] = 'DNS:localhost';
$sanParts[] = 'IP:127.0.0.1';
} else {
$sanParts[] = 'IP:127.0.0.1';
}
$san = implode(',', $sanParts);
$cmd = implode(' ', [
'openssl', 'req',
'-x509',
'-newkey', 'rsa:2048',
'-keyout', escapeshellarg($keyPath),
'-out', escapeshellarg($certPath),
'-days', '3650',
'-nodes',
'-config', escapeshellarg($opensslCnf),
'-subj', escapeshellarg($subj),
'-addext', escapeshellarg("subjectAltName={$san}"),
'2>&1',
]);
exec($cmd, $output, $exitCode);
if ($exitCode !== 0 || !file_exists($certPath) || !file_exists($keyPath)) {
logMsg('DEBUG', 'openssl CLI unavailable or failed: ' . implode(' | ', $output), $logFile);
return false;
}
// Append key to cert file ? PHP ssl:// local_cert can load a combined PEM
$certPem = file_get_contents($certPath);
$keyPem = file_get_contents($keyPath);
file_put_contents($certPath, $certPem . $keyPem);
chmod($certPath, 0600);
chmod($keyPath, 0600);
logMsg('INFO', "Certificate generated for CN={$cn} (openssl CLI), stored in {$certDir}/", $logFile);
return true;
}
// ?? Daemonization ?????????????????????????????????????????????????????????????
function daemonize(string $pidFile, string $logFile): void
{
if (!function_exists('pcntl_fork')) {
logMsg('WARNING', 'pcntl extension not available ? running in foreground', $logFile);
return;
}
$pid = pcntl_fork();
if ($pid < 0) {
throw new \RuntimeException('pcntl_fork() failed');
}
if ($pid > 0) {
exit(0); // parent exits
}
posix_setsid();
$pid2 = pcntl_fork();
if ($pid2 < 0) {
throw new \RuntimeException('Second pcntl_fork() failed');
}
if ($pid2 > 0) {
exit(0);
}
// Redirect standard streams
fclose(STDIN);
fclose(STDOUT);
fclose(STDERR);
fopen('/dev/null', 'r');
fopen('/dev/null', 'w');
fopen('/dev/null', 'w');
define('DAEMON_MODE', true);
writePidFile($pidFile);
}
function writePidFile(string $pidFile): void
{
$dir = dirname($pidFile);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($pidFile, getmypid());
}
function cleanupPidFile(string $pidFile): void
{
if (file_exists($pidFile)) {
unlink($pidFile);
}
}
// ?? Gemini response helpers ???????????????????????????????????????????????????
/**
* Write a Gemini status line and optional body to a socket.
*
* @param resource $socket
* @param int $status Gemini status code (e.g. 20, 51)
* @param string $meta MIME type for success, error message otherwise
* @param string $body Response body (for status 2x)
*/
function geminiRespond($socket, int $status, string $meta, string $body = ''): void
{
fwrite($socket, "{$status} {$meta}\r\n");
if ($body !== '') {
fwrite($socket, $body);
}
}
// ?? Route handlers ????????????????????????????????????????????????????????????
/**
* BBS home page ? list users who have at least one published capsule.
*
* @param resource $socket
* @param string $geminiHost The hostname used in gemini:// links
*/
function handleHomePage($socket, string $geminiHost): void
{
try {
$db = Database::getInstance()->getPdo();
$binkpConfig = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance();
$bbsName = $binkpConfig->getSystemName();
$siteUrl = Config::getSiteUrl();
$siteDescription = trim(AppearanceConfig::getSeoDescription());
$stmt = $db->query(
'SELECT DISTINCT u.username
FROM users u
JOIN gemini_capsule_files f ON f.user_id = u.id
WHERE f.is_published = TRUE AND u.is_active = TRUE
ORDER BY u.username'
);
$users = $stmt->fetchAll(\PDO::FETCH_COLUMN);
$lines = [
"# {$bbsName}",
'',
];
if ($siteDescription !== '') {
$lines[] = $siteDescription;
$lines[] = '';
}
$lines = array_merge($lines, [
"=> {$siteUrl}/ Visit {$bbsName} on the web",
'',
'## Gemini Capsule Directory',
'',
'These BBS users have published their Gemini capsules here.',
'',
]);
if (empty($users)) {
$lines[] = 'No capsules have been published yet.';
} else {
foreach ($users as $username) {
$lines[] = "=> gemini://{$geminiHost}/home/" . rawurlencode($username) . "/ {$username}";
}
}
$lines[] = '';
$lines[] = '## Bulletin Board List';
$lines[] = '';
$lines[] = 'Browse active bulletin board systems, including their names, locations, and telnet addresses.';
$lines[] = '';
$lines[] = "=> gemini://{$geminiHost}/bbs-directory/ Browse the bulletin board list";
// File areas
$fileAreaStmt = $db->query(
'SELECT tag, description FROM file_areas
WHERE gemini_public = TRUE AND is_active = TRUE AND is_private = FALSE
ORDER BY tag ASC'
);
$fileAreas = $fileAreaStmt->fetchAll(\PDO::FETCH_ASSOC);
if (!empty($fileAreas)) {
$lines[] = '';
$lines[] = '## File Areas';
$lines[] = '';
$lines[] = 'Downloadable files are available in the following areas.';
$lines[] = '';
$lines[] = "=> gemini://{$geminiHost}/files/ Browse all file areas";
$lines[] = '';
foreach ($fileAreas as $fa) {
$faTag = strtoupper((string)$fa['tag']);
$faDesc = trim((string)($fa['description'] ?? ''));
$label = $faDesc !== '' ? "{$faTag} ? {$faDesc}" : $faTag;
$lines[] = "=> gemini://{$geminiHost}/files/{$faTag}/ {$label}";
}
}
// Echo areas
$areaStmt = $db->query(
'SELECT tag, domain, description FROM echoareas
WHERE gemini_public = TRUE AND is_active = TRUE
ORDER BY domain, tag'
);
$areas = $areaStmt->fetchAll(\PDO::FETCH_ASSOC);
if (!empty($areas)) {
$lines[] = '';
$lines[] = '## Echo Areas';
$lines[] = '';
$lines[] = 'A selection of public echo areas are available to read below. Join the BBS for a full list of echo areas and to participate in the conversation!';
$lines[] = "=> {$siteUrl}/ Join {$bbsName}";
$lines[] = '';
foreach ($areas as $area) {
$identifier = strtoupper($area['tag']) . '@' . strtolower($area['domain']);
$lines[] = "=> gemini://{$geminiHost}/echomail/{$identifier}/ {$identifier} ? {$area['description']}";
}
}
// Echo area stats by network (all active areas)
$statsStmt = $db->query(
"SELECT
CASE
WHEN is_local = TRUE OR domain IS NULL OR TRIM(domain) = '' THEN 'local'
ELSE LOWER(TRIM(domain))
END AS network,
COUNT(*) AS area_count,
COALESCE(SUM(message_count), 0) AS message_count
FROM echoareas
WHERE is_active = TRUE
GROUP BY 1"
);
$networkStats = $statsStmt->fetchAll(\PDO::FETCH_ASSOC);
if (!empty($networkStats)) {
usort($networkStats, function (array $a, array $b): int {
$rank = function (string $network): int {
if ($network === 'local') {
return 0;
}
if ($network === 'lovlynet' || $network === 'lovelynet') {
return 1;
}
return 2;
};
$aNet = (string)$a['network'];
$bNet = (string)$b['network'];
$aRank = $rank($aNet);
$bRank = $rank($bNet);
if ($aRank !== $bRank) {
return $aRank <=> $bRank;
}
return strcmp($aNet, $bNet);
});
$lines[] = '';
$lines[] = '## Echo Message area stats by network';
$lines[] = '';
foreach ($networkStats as $row) {
$network = (string)$row['network'];
$areaCount = (int)$row['area_count'];
$messageCount = (int)$row['message_count'];
$label = ($network === 'local') ? 'Local' : strtoupper($network);
if ($network === 'lovlynet') {
$label = 'LOVLYNET';
} elseif ($network === 'lovelynet') {
$label = 'LOVELYNET';
}
$lines[] = "* {$label}: {$areaCount} message areas, {$messageCount} messages";
}
}
geminiRespond($socket, 20, 'text/gemini; charset=utf-8', implode("\n", $lines) . "\n");
} catch (\Exception $e) {
geminiRespond($socket, 40, 'Temporary server error');
}
}
/**
* User capsule index ? serve index.gmi if published, otherwise auto-list files.
*
* @param resource $socket
* @param string $username
* @param string $geminiHost
*/
function handleUserIndex($socket, string $username, string $geminiHost): void
{
try {
$db = Database::getInstance()->getPdo();
// Look up user (case-insensitive)
$stmt = $db->prepare(
'SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND is_active = TRUE'
);
$stmt->execute([$username]);
$user = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$user) {
geminiRespond($socket, 51, 'Not Found');
return;
}
$userId = (int)$user['id'];
// Try to serve index.gmi if published
$stmt = $db->prepare(
"SELECT content FROM gemini_capsule_files
WHERE user_id = ? AND filename = 'index.gmi' AND is_published = TRUE"
);
$stmt->execute([$userId]);
$index = $stmt->fetch(\PDO::FETCH_ASSOC);
if ($index) {
geminiRespond($socket, 20, 'text/gemini; charset=utf-8', $index['content']);
return;
}
// Auto-generate directory listing of published files
$stmt = $db->prepare(
'SELECT filename FROM gemini_capsule_files
WHERE user_id = ? AND is_published = TRUE
ORDER BY filename'
);
$stmt->execute([$userId]);
$files = $stmt->fetchAll(\PDO::FETCH_COLUMN);
if (empty($files)) {
geminiRespond($socket, 51, 'Not Found');
return;
}
$lines = [
"# {$username}'s Capsule",
'',
];
foreach ($files as $filename) {
$lines[] = "=> /home/" . rawurlencode($username) . "/{$filename} {$filename}";
}
geminiRespond($socket, 20, 'text/gemini; charset=utf-8', implode("\n", $lines) . "\n");
} catch (\Exception $e) {
geminiRespond($socket, 40, 'Temporary server error');
}
}
/**
* Serve a specific published capsule file.
*
* @param resource $socket
* @param string $username
* @param string $filename
*/
function handleUserFile($socket, string $username, string $filename): void
{
// Validate filename format
if (!preg_match('/^[a-zA-Z0-9_\-]+\.(gmi|gemini)$/', $filename)) {
geminiRespond($socket, 51, 'Not Found');
return;
}
try {
$db = Database::getInstance()->getPdo();
// Look up user (case-insensitive)
$stmt = $db->prepare(
'SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND is_active = TRUE'
);
$stmt->execute([$username]);
$user = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$user) {
geminiRespond($socket, 51, 'Not Found');
return;
}
$stmt = $db->prepare(
'SELECT content, filename FROM gemini_capsule_files
WHERE user_id = ? AND filename = ? AND is_published = TRUE'
);
$stmt->execute([(int)$user['id'], $filename]);
$file = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$file) {
geminiRespond($socket, 51, 'Not Found');
return;
}
$ext = strtolower(pathinfo($file['filename'], PATHINFO_EXTENSION));
$mime = in_array($ext, ['gmi', 'gemini'])
? 'text/gemini; charset=utf-8'
: 'text/plain; charset=utf-8';
geminiRespond($socket, 20, $mime, $file['content']);
} catch (\Exception $e) {
geminiRespond($socket, 40, 'Temporary server error');
}
}
// ?? Echo area route handlers ??????????????????????????????????????????????????
/**
* List all echo areas with gemini_public = TRUE.
*
* @param resource $socket
* @param string $geminiHost
*/
function handleEchoAreaList($socket, string $geminiHost): void
{
try {
$db = Database::getInstance()->getPdo();
$stmt = $db->query(
'SELECT tag, domain, description
FROM echoareas
WHERE gemini_public = TRUE AND is_active = TRUE
ORDER BY domain, tag'
);
$areas = $stmt->fetchAll(\PDO::FETCH_ASSOC);
$lines = [
'# Echo Areas',
'',
'Public echo areas on this BBS, served read-only.',
'',
];
if (empty($areas)) {
$lines[] = 'No public echo areas are available.';
} else {
foreach ($areas as $area) {
$tag = strtoupper($area['tag']);
$domain = strtolower($area['domain'] ?? '');
$identifier = $domain !== '' ? "{$tag}@{$domain}" : $tag;
$lines[] = "=> gemini://{$geminiHost}/echomail/{$identifier}/ {$identifier} ? {$area['description']}";
}
}
$binkpConfig = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance();
$bbsName = $binkpConfig->getSystemName();
$lines[] = '';
$lines[] = "=> gemini://{$geminiHost}/ Return to main index for {$bbsName}";
geminiRespond($socket, 20, 'text/gemini; charset=utf-8', implode("\n", $lines) . "\n");
} catch (\Exception $e) {
geminiRespond($socket, 40, 'Temporary server error');
}
}
/**
* Look up a gemini_public echo area by tag and domain.
* Returns the area row or null if not found / not public.
*/
function findPublicEchoArea(\PDO $db, string $tag, string $domain): ?array
{
$stmt = $db->prepare(
'SELECT id, tag, domain, description
FROM echoareas
WHERE UPPER(tag) = UPPER(?) AND LOWER(domain) = LOWER(?)
AND gemini_public = TRUE AND is_active = TRUE'
);
$stmt->execute([$tag, $domain]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
return $row ?: null;
}
/**
* List the 50 most recent messages in a public echo area.
*
* @param resource $socket
* @param string $tag
* @param string $domain
* @param string $geminiHost
*/
function handleEchoMessages($socket, string $tag, string $domain, string $geminiHost): void
{
try {
$db = Database::getInstance()->getPdo();
$area = findPublicEchoArea($db, $tag, $domain);
if (!$area) {
geminiRespond($socket, 51, 'Not Found');
return;
}
$stmt = $db->prepare(
'SELECT em.id, em.from_name, em.subject, em.date_written
FROM echomail em
JOIN echoareas ea ON em.echoarea_id = ea.id
WHERE ea.id = ?
ORDER BY em.date_written DESC
LIMIT 50'
);
$stmt->execute([(int)$area['id']]);
$messages = $stmt->fetchAll(\PDO::FETCH_ASSOC);
$identifier = strtoupper($area['tag']) . '@' . strtolower($area['domain']);
$lines = [
"# {$identifier} ? {$area['description']}",
'',
'50 most recent messages. Oldest first.',
'',
];
if (empty($messages)) {
$lines[] = 'No messages in this area yet.';
} else {
// Reverse so oldest is displayed first (top-to-bottom chronological)
$messages = array_reverse($messages);
foreach ($messages as $msg) {
$date = date('Y-m-d', strtotime($msg['date_written']));
$subject = $msg['subject'] ?? '(no subject)';
$from = $msg['from_name'] ?? '';
$lines[] = "=> gemini://{$geminiHost}/echomail/{$identifier}/{$msg['id']} {$date} {$subject} ({$from})";
}
}
$lines[] = '';
$lines[] = "=> gemini://{$geminiHost}/echomail/ Back to Echo Areas";
geminiRespond($socket, 20, 'text/gemini; charset=utf-8', implode("\n", $lines) . "\n");
} catch (\Exception $e) {
geminiRespond($socket, 40, 'Temporary server error');
}
}
/**
* Serve a single echomail message from a public echo area.
*
* @param resource $socket
* @param string $tag
* @param string $domain
* @param int $id
* @param string $geminiHost
*/
function handleEchoMessage($socket, string $tag, string $domain, int $id, string $geminiHost): void
{
try {
$db = Database::getInstance()->getPdo();
$area = findPublicEchoArea($db, $tag, $domain);
if (!$area) {
geminiRespond($socket, 51, 'Not Found');
return;
}
$stmt = $db->prepare(
'SELECT em.id, em.from_name, em.from_address, em.to_name,
em.subject, em.date_written, em.message_text, em.origin_line
FROM echomail em
JOIN echoareas ea ON em.echoarea_id = ea.id
WHERE em.id = ? AND ea.id = ?'
);
$stmt->execute([$id, (int)$area['id']]);
$msg = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$msg) {
geminiRespond($socket, 51, 'Not Found');
return;
}
$identifier = strtoupper($area['tag']) . '@' . strtolower($area['domain']);
$subject = $msg['subject'] ?? '(no subject)';
$from = $msg['from_name'] ?? '';
$fromAddr = $msg['from_address'] ? " ({$msg['from_address']})" : '';
$to = $msg['to_name'] ?? '';
$date = $msg['date_written'] ? date('Y-m-d H:i:s', strtotime($msg['date_written'])) : '';
$body = $msg['message_text'] ?? '';
$lines = [
"# {$subject}",
'',
"From: {$from}{$fromAddr}",
"To: {$to}",
"Date: {$date}",
'',
'---',
'',
'```',
$body,
'```',
'',
'---',
];
if (!empty($msg['origin_line'])) {
$lines[] = '* ' . $msg['origin_line'];
}
$lines[] = '';
$lines[] = "=> gemini://{$geminiHost}/echomail/{$identifier}/ Back to {$identifier}";
geminiRespond($socket, 20, 'text/gemini; charset=utf-8', implode("\n", $lines) . "\n");
} catch (\Exception $e) {
geminiRespond($socket, 40, 'Temporary server error');
}
}
// ?? Request router ????????????????????????????????????????????????????????????
/**
* List active BBS directory entries with location, telnet address, and website.
*
* @param resource $socket
* @param string $geminiHost
*/
function handleBbsDirectoryList($socket, string $geminiHost): void
{
try {
$db = Database::getInstance()->getPdo();
$stmt = $db->query(
"SELECT name, location, telnet_host, telnet_port, website
FROM bbs_directory
WHERE status = 'active'
AND telnet_host IS NOT NULL
AND BTRIM(telnet_host) <> ''
ORDER BY LOWER(name) ASC"
);
$entries = $stmt->fetchAll(\PDO::FETCH_ASSOC);
$binkpConfig = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance();
$bbsName = $binkpConfig->getSystemName();
$lines = [
'# Bulletin Board List',
'',
'Active BBS listings with location, telnet address, and website.',
'',
];
if (empty($entries)) {
$lines[] = 'No BBS listings are available.';
} else {
foreach ($entries as $entry) {
$name = trim((string)($entry['name'] ?? 'Unnamed BBS'));
$location = trim((string)($entry['location'] ?? ''));
$telnetHost = trim((string)($entry['telnet_host'] ?? ''));
$telnetPort = (int)($entry['telnet_port'] ?? 23);
$website = trim((string)($entry['website'] ?? ''));
if ($website !== '' && !preg_match('#^[a-zA-Z][a-zA-Z0-9+\-.]*://#', $website)) {
$website = 'https://' . $website;
}
$telnetLabel = ($telnetPort > 0 && $telnetPort !== 23)
? "{$telnetHost}:{$telnetPort}"
: $telnetHost;
$lines[] = "## {$name}";
if ($location !== '') {
$lines[] = "Location: {$location}";
}
$lines[] = "Telnet: {$telnetLabel}";
if ($website !== '') {
$lines[] = "Website: {$website}";
}
$lines[] = "=> telnet://{$telnetLabel}/ Connect to {$name}";
if ($website !== '') {
$lines[] = "=> {$website} Visit {$name} on the web";
}
$lines[] = '';
}
array_pop($lines);
}
$lines[] = '';
$lines[] = "=> gemini://{$geminiHost}/ Return to main index for {$bbsName}";
geminiRespond($socket, 20, 'text/gemini; charset=utf-8', implode("\n", $lines) . "\n");
} catch (\Exception $e) {
geminiRespond($socket, 40, 'Temporary server error');
}
}
/**
* Map a file extension to a MIME type for Gemini responses.
*
* @param string $filename
* @return string MIME type string
*/
function fileMimeType(string $filename): string
{
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
$map = [
// Text
'txt' => 'text/plain',
'md' => 'text/plain',
'gmi' => 'text/gemini',
'gemini' => 'text/gemini',
'diz' => 'text/plain',
'nfo' => 'text/plain',
'log' => 'text/plain',
'cfg' => 'text/plain',
'ini' => 'text/plain',
// Archives
'zip' => 'application/zip',
'gz' => 'application/gzip',
'tar' => 'application/x-tar',
'bz2' => 'application/x-bzip2',
'xz' => 'application/x-xz',
'rar' => 'application/vnd.rar',
'arj' => 'application/x-arj',
'lzh' => 'application/x-lzh-compressed',
'lha' => 'application/x-lzh-compressed',
'arc' => 'application/x-arc',
'zoo' => 'application/x-zoo',
'7z' => 'application/x-7z-compressed',
// Images
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'bmp' => 'image/bmp',
'ico' => 'image/x-icon',
// Documents
'pdf' => 'application/pdf',
// Executables / DOS
'exe' => 'application/octet-stream',
'com' => 'application/octet-stream',
'bat' => 'text/plain',
// Audio / Video
'mp3' => 'audio/mpeg',
'ogg' => 'audio/ogg',
'wav' => 'audio/wav',
'mp4' => 'video/mp4',
'avi' => 'video/x-msvideo',
];
return $map[$ext] ?? 'application/octet-stream';
}
/**
* Stream a binary or text file to a Gemini socket.
* Reads in 64 KB chunks to avoid loading large files into memory.
*
* @param resource $socket
* @param string $mime MIME type for the Gemini header
* @param string $filePath Absolute path to the file
*/
function geminiStreamFile($socket, string $mime, string $filePath): void
{
fwrite($socket, "20 {$mime}\r\n");
$fh = fopen($filePath, 'rb');
if ($fh === false) {
return;
}
while (!feof($fh)) {
$chunk = fread($fh, 65536);
if ($chunk === false) {
break;
}
fwrite($socket, $chunk);
}
fclose($fh);
}
/**
* List all Gemini-public file areas.
*
* @param resource $socket
* @param string $geminiHost
*/
function handleFileAreaList($socket, string $geminiHost): void
{
try {
$db = Database::getInstance()->getPdo();
$binkpConfig = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance();
$bbsName = $binkpConfig->getSystemName();
$stmt = $db->query(
"SELECT tag, description, domain, file_count, total_size
FROM file_areas
WHERE gemini_public = TRUE AND is_active = TRUE AND is_private = FALSE
ORDER BY tag ASC"
);
$areas = $stmt->fetchAll(\PDO::FETCH_ASSOC);
$lines = [
'# File Areas',
'',
'Publicly available file areas on ' . $bbsName . '.',
'',
];
if (empty($areas)) {
$lines[] = 'No file areas are currently available for download.';
} else {
foreach ($areas as $area) {
$tag = strtoupper((string)$area['tag']);
$desc = trim((string)($area['description'] ?? ''));
$count = (int)($area['file_count'] ?? 0);
$size = (int)($area['total_size'] ?? 0);
$sizeLabel = '';
if ($size > 0) {
$sizeLabel = $size >= 1048576
? ' ? ' . round($size / 1048576, 1) . ' MB'
: ' ? ' . round($size / 1024, 0) . ' KB';
}
$countLabel = $count === 1 ? '1 file' : "{$count} files";
$label = $desc !== '' ? "{$tag} ? {$desc} ({$countLabel}{$sizeLabel})" : "{$tag} ({$countLabel}{$sizeLabel})";
$lines[] = "=> gemini://{$geminiHost}/files/{$tag}/ {$label}";
}
}
$lines[] = '';
$lines[] = "=> gemini://{$geminiHost}/ Return to main index";
geminiRespond($socket, 20, 'text/gemini; charset=utf-8', implode("\n", $lines) . "\n");
} catch (\Exception $e) {
geminiRespond($socket, 40, 'Temporary server error');
}
}
/**
* List files in a specific Gemini-public file area.
*
* @param resource $socket
* @param string $tag Area tag (case-insensitive)
* @param string $geminiHost
*/
function handleFileAreaDirectory($socket, string $tag, string $geminiHost): void
{
try {
$db = Database::getInstance()->getPdo();
// Validate area exists and is gemini_public
$areaStmt = $db->prepare(
"SELECT id, tag, description FROM file_areas
WHERE UPPER(tag) = UPPER(?) AND gemini_public = TRUE AND is_active = TRUE AND is_private = FALSE
LIMIT 1"
);
$areaStmt->execute([$tag]);
$area = $areaStmt->fetch(\PDO::FETCH_ASSOC);
if (!$area) {
geminiRespond($socket, 51, 'File area not found');
return;
}
$areaTag = strtoupper((string)$area['tag']);
$areaDesc = trim((string)($area['description'] ?? ''));
$filesStmt = $db->prepare(
"SELECT filename, filesize, short_description, created_at
FROM files
WHERE file_area_id = ? AND status = 'approved'
ORDER BY filename ASC"
);
$filesStmt->execute([$area['id']]);
$files = $filesStmt->fetchAll(\PDO::FETCH_ASSOC);
$title = $areaDesc !== '' ? "{$areaTag} ? {$areaDesc}" : $areaTag;
$lines = [
"# {$title}",
'',
];
if (empty($files)) {
$lines[] = 'No files are available in this area.';
} else {
foreach ($files as $file) {
$name = (string)$file['filename'];
$size = (int)$file['filesize'];
$desc = trim((string)($file['short_description'] ?? ''));
$date = substr((string)($file['created_at'] ?? ''), 0, 10);
$sizeLabel = $size >= 1048576
? round($size / 1048576, 1) . ' MB'
: round($size / 1024, 0) . ' KB';
$label = $desc !== '' ? "{$name} ({$sizeLabel}) ? {$desc}" : "{$name} ({$sizeLabel})";
if ($date !== '') {
$label .= " [{$date}]";
}
$lines[] = "=> gemini://{$geminiHost}/files/{$areaTag}/" . rawurlencode($name) . " {$label}";
}
}
$lines[] = '';
$lines[] = "=> gemini://{$geminiHost}/files/ Back to File Areas";
geminiRespond($socket, 20, 'text/gemini; charset=utf-8', implode("\n", $lines) . "\n");
} catch (\Exception $e) {
geminiRespond($socket, 40, 'Temporary server error');
}
}
/**
* Serve a single file from a Gemini-public file area.
*
* @param resource $socket
* @param string $tag Area tag (case-insensitive)
* @param string $filename Requested filename (URL-decoded by caller)
*/
function handleFileAreaDownload($socket, string $tag, string $filename): void
{
try {
$db = Database::getInstance()->getPdo();
// Resolve area
$areaStmt = $db->prepare(
"SELECT id FROM file_areas
WHERE UPPER(tag) = UPPER(?) AND gemini_public = TRUE AND is_active = TRUE AND is_private = FALSE
LIMIT 1"
);
$areaStmt->execute([$tag]);
$area = $areaStmt->fetch(\PDO::FETCH_ASSOC);
if (!$area) {
geminiRespond($socket, 51, 'File area not found');
return;
}
// Resolve file (case-insensitive filename match)
$fileStmt = $db->prepare(
"SELECT filename, storage_path, filesize FROM files
WHERE file_area_id = ? AND LOWER(filename) = LOWER(?) AND status = 'approved'
LIMIT 1"
);
$fileStmt->execute([$area['id'], $filename]);
$file = $fileStmt->fetch(\PDO::FETCH_ASSOC);
if (!$file) {
geminiRespond($socket, 51, 'File not found');
return;
}
$storagePath = (string)$file['storage_path'];
if (!file_exists($storagePath) || !is_readable($storagePath)) {
geminiRespond($socket, 40, 'File not available');
return;
}
$mime = fileMimeType((string)$file['filename']);
geminiStreamFile($socket, $mime, $storagePath);
} catch (\Exception $e) {
geminiRespond($socket, 40, 'Temporary server error');
}
}
/**
* Handle a single Gemini connection: read request, route, respond, close.
* The socket is already TLS-encrypted (handshake completed by ssl:// accept).
*
* @param resource $socket Accepted TLS client socket
* @param string $geminiHost
* @param string $logFile
*/
function handleConnection($socket, string $geminiHost, string $logFile): void
{
stream_set_timeout($socket, 10);
// Read request line (max 1026 bytes: 1024 URL + \r\n)
$requestLine = fgets($socket, 1028);
if ($requestLine === false) {
fclose($socket);
return;
}
$requestLine = rtrim($requestLine, "\r\n");
if (strlen($requestLine) === 0) {
geminiRespond($socket, 59, 'Bad Request');
fclose($socket);
return;
}
// Validate gemini:// scheme
if (!preg_match('/^gemini:\/\//i', $requestLine)) {
geminiRespond($socket, 59, 'Only gemini:// URLs are supported');
fclose($socket);
return;
}
$parsed = parse_url($requestLine);
$path = $parsed['path'] ?? '/';
if ($path === '') {
$path = '/';
}
$remoteAddr = stream_socket_get_name($socket, true) ?: 'unknown';
// Strip port, keep only the IP address
$remoteIp = preg_replace('/:\d+$/', '', $remoteAddr);
logMsg('INFO', "[{$remoteIp}] Request: {$requestLine}", $logFile);
// Route
if ($path === '/' || $path === '') {
handleHomePage($socket, $geminiHost);
} elseif ($path === '/bbs-directory/' || $path === '/bbs-directory') {
handleBbsDirectoryList($socket, $geminiHost);
} elseif (preg_match('#^/home/([^/]+)/$#', $path, $m)) {
handleUserIndex($socket, rawurldecode($m[1]), $geminiHost);
} elseif (preg_match('#^/home/([^/]+)/([^/]+)$#', $path, $m)) {
handleUserFile($socket, rawurldecode($m[1]), rawurldecode($m[2]));
} elseif ($path === '/echomail/' || $path === '/echomail') {
handleEchoAreaList($socket, $geminiHost);
} elseif (preg_match('#^/echomail/([A-Za-z0-9._-]+)@([A-Za-z0-9._-]+)/$#', $path, $m)) {
handleEchoMessages($socket, $m[1], $m[2], $geminiHost);
} elseif (preg_match('#^/echomail/([A-Za-z0-9._-]+)@([A-Za-z0-9._-]+)/(\d+)$#', $path, $m)) {
handleEchoMessage($socket, $m[1], $m[2], (int)$m[3], $geminiHost);
} elseif ($path === '/files/' || $path === '/files') {
handleFileAreaList($socket, $geminiHost);
} elseif (preg_match('#^/files/([A-Za-z0-9._-]+)/$#', $path, $m)) {
handleFileAreaDirectory($socket, $m[1], $geminiHost);
} elseif (preg_match('#^/files/([A-Za-z0-9._-]+)/(.+)$#', $path, $m)) {
handleFileAreaDownload($socket, $m[1], rawurldecode($m[2]));
} else {
geminiRespond($socket, 51, 'Not Found');
}
fclose($socket);
}
// ?? Main ??????????????????????????????????????????????????????????????????????
// Ensure log directory exists
$logDir = dirname($logFile);
if (!is_dir($logDir)) {
mkdir($logDir, 0755, true);
}
// Generate self-signed TLS cert only if no external cert is configured
if ($externalCert) {
logMsg('INFO', "Using external TLS certificate: {$certPath}", $logFile);
if (!file_exists($certPath)) {
logMsg('ERROR', "GEMINI_CERT_PATH does not exist: {$certPath}", $logFile);
exit(1);
}
if (!file_exists($keyPath)) {
logMsg('ERROR', "GEMINI_KEY_PATH does not exist: {$keyPath}", $logFile);
exit(1);
}
} else {
generateSelfSignedCert($certDir, $certPath, $keyPath, $logFile);
}
// Resolve to clean absolute paths ? OpenSSL's C layer on Windows cannot resolve
// paths containing ".." (e.g. "scripts/../data/gemini/server.crt"), which causes
// silent cert-load failures and the server to send 0 bytes during TLS handshake.
$certPath = realpath($certPath) ?: $certPath;
$keyPath = realpath($keyPath) ?: $keyPath;
logMsg('DEBUG', "Using cert: {$certPath}", $logFile);
// Daemonize if requested
if ($daemonMode) {
daemonize($pidFile, $logFile);
} else {
writePidFile($pidFile);
}
// Derive hostname for gemini:// links from SITE_URL
$geminiHost = 'localhost';
try {
$parsed = parse_url(Config::getSiteUrl());
if (!empty($parsed['host'])) {
$geminiHost = $parsed['host'];
}
} catch (\Exception $e) {
// fall back to localhost
}
// Build SSL stream context.
// Self-signed certs are stored as combined PEM (cert + key) so local_pk is not needed.
// External certs (e.g. Let's Encrypt) use separate cert and key files.
$sslOptions = [
'local_cert' => $certPath,
'allow_self_signed' => true,
'verify_peer' => false,
'verify_peer_name' => false,
];
if ($externalCert) {
$sslOptions['local_pk'] = $keyPath;
}
$sslCtx = stream_context_create(['ssl' => $sslOptions]);
// Open a plain TCP server socket. TLS is negotiated per-connection in the
// child process (after fork) so the parent's fclose() does not send a TLS
// close_notify alert to the client before the child has responded.
$server = @stream_socket_server(
"tcp://{$host}:{$port}",
$errno,
$errstr,
STREAM_SERVER_BIND | STREAM_SERVER_LISTEN,
$sslCtx
);
if ($server === false) {
logMsg('ERROR', "Failed to bind tcp://{$host}:{$port} ? {$errstr} (errno {$errno})", $logFile);
cleanupPidFile($pidFile);
exit(1);
}
logMsg('INFO', "Gemini capsule server listening on tcp://{$host}:{$port}", $logFile);
logMsg('INFO', "Serving capsules at gemini://{$geminiHost}/", $logFile);
// Signal handlers
if (function_exists('pcntl_signal')) {
$shutdown = function () use ($server, $pidFile, $logFile) {
logMsg('INFO', 'Shutting down...', $logFile);
fclose($server);
cleanupPidFile($pidFile);
exit(0);
};
pcntl_signal(SIGTERM, $shutdown);
pcntl_signal(SIGINT, $shutdown);
pcntl_signal(SIGCHLD, function () {
while (function_exists('pcntl_waitpid') && pcntl_waitpid(-1, $status, WNOHANG) > 0) {
// reap zombie children
}
});
if (function_exists('pcntl_async_signals')) {
pcntl_async_signals(true);
}
}
// Accept loop
while (true) {
$client = @stream_socket_accept($server, 30);
if ($client === false) {
continue;
}
if (!function_exists('pcntl_fork')) {
// No fork available ? negotiate TLS inline, handle single connection at a time
$ok = @stream_socket_enable_crypto($client, true, STREAM_CRYPTO_METHOD_TLS_SERVER);
if ($ok !== true) {
$logged = false;
while (($sslErr = openssl_error_string()) !== false) {
logMsg('WARNING', "TLS handshake failed (no-fork): {$sslErr}", $logFile);
$logged = true;
}
if (!$logged) {
logMsg('WARNING', 'TLS handshake failed (no-fork)', $logFile);
}
fclose($client);
continue;
}
handleConnection($client, $geminiHost, $logFile);
continue;
}
$pid = pcntl_fork();
if ($pid < 0) {
logMsg('ERROR', 'pcntl_fork() failed', $logFile);
fclose($client);
continue;
}
if ($pid === 0) {
// Child: negotiate TLS then handle the request
fclose($server);
$ok = @stream_socket_enable_crypto($client, true, STREAM_CRYPTO_METHOD_TLS_SERVER);
if ($ok !== true) {
$logged = false;
while (($sslErr = openssl_error_string()) !== false) {
logMsg('WARNING', "TLS handshake failed: {$sslErr}", $logFile);
$logged = true;
}
if (!$logged) {
logMsg('WARNING', 'TLS handshake failed', $logFile);
}
fclose($client);
exit(0);
}
handleConnection($client, $geminiHost, $logFile);
exit(0);
}
// Parent: close the plain TCP socket ? no TLS teardown, no close_notify sent
fclose($client);
}
|