PHP Classes

File: scripts/gemini_daemon.php

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   scripts/gemini_daemon.php   Download  
File: scripts/gemini_daemon.php
Role: Example script
Content type: text/plain
Description: Example script
Class: Binkterm PHP
Bulletin board system based on the Web
Author: By
Last change:
Date: 6 days ago
Size: 47,631 bytes
 

Contents

Class file image Download
#!/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); }