1<?php 2 3use splitbrain\phpcli\Colors; 4use splitbrain\phpcli\Options; 5 6/** 7 * DokuWiki Plugin gemini (CLI Component) 8 * 9 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 10 * @author Andreas Gohr <andi@splitbrain.org> 11 */ 12class cli_plugin_gemini extends \dokuwiki\Extension\CLIPlugin 13{ 14 15 /** @inheritDoc */ 16 protected function setup(Options $options) 17 { 18 $options->setHelp('Starts a Gemini Protocol server and serves the wiki as GemText'); 19 20 // options 21 $options->registerOption( 22 'interface', 23 'The IP to listen on. Defaults to ' . $this->colors->wrap('0.0.0.0', Colors::C_CYAN), 24 'i', 25 'ip' 26 ); 27 $options->registerOption( 28 'port', 29 'The port to listen on. Defaults to ' . $this->colors->wrap('1965', Colors::C_CYAN), 30 'p', 31 'port' 32 ); 33 $options->registerOption( 34 'hostname', 35 'The hostname this server shall use. Defaults to ' . $this->colors->wrap('localhost', Colors::C_CYAN), 36 's', 37 'host' 38 ); 39 $options->registerOption( 40 'certfile', 41 'Path to a PEM formatted TLS certificate to use. The common name should match the hostname. ' . 42 'If none is given a self-signed one is auto-generated.', 43 'c', 44 'cert' 45 ); 46 47 } 48 49 /** @inheritDoc */ 50 protected function main(Options $options) 51 { 52 $interface = $options->getOpt('interface', '0.0.0.0'); 53 $port = $options->getOpt('port', '1965'); 54 $host = $options->getOpt('host', 'localhost'); 55 $pemfile = $options->getOpt('certfile'); 56 if (!$pemfile) $pemfile = $this->getSelfSignedCertificate($host); 57 $this->notice('Using certificate in {pemfile}', compact('pemfile')); 58 $this->serve($interface, $port, $pemfile); 59 } 60 61 /** 62 * The actual socket server implementation 63 * 64 * @param string $interface IP to listen on 65 * @param int $port Port to use 66 * @param string $certfile Certificate PEM file to use 67 * @return mixed 68 */ 69 protected function serve($interface, $port, $certfile) 70 { 71 $context = stream_context_create([ 72 'ssl' => [ 73 'verify_peer' => false, 74 'local_cert' => $certfile, 75 ], 76 ] 77 ); 78 79 if (function_exists('pcntl_fork')) { 80 $this->notice('Multithreading enabled.'); 81 } else { 82 $this->notice('Multithreading disabled (PCNTL extension not present)'); 83 } 84 85 $errno = 0; 86 $errstr = ''; 87 $socket = stream_socket_server( 88 'tcp://' . $interface . ':' . $port, 89 $errno, 90 $errstr, 91 STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, 92 $context 93 ); 94 if ($socket === false) throw new \splitbrain\phpcli\Exception($errstr, $errno); 95 $this->success('Listening on {interface}:{port}', compact('interface', 'port')); 96 97 // basic environment 98 global $_SERVER; 99 $_SERVER['SERVER_ADDR'] = $interface; 100 $_SERVER['SERVER_PORT'] = $port; 101 $_SERVER['SERVER_PROTOCOL'] = 'gemini'; 102 $_SERVER['REQUEST_SCHEME'] = 'gemini'; 103 $_SERVER['HTTPS'] = 'on'; 104 105 while (true) { 106 $peername = ''; 107 $conn = stream_socket_accept($socket, -1, $peername); 108 if ($conn === false) throw new \splitbrain\phpcli\Exception('socket failed'); 109 110 if (!function_exists('pcntl_fork') || ($pid = pcntl_fork()) == -1) { 111 $pid = -1; 112 } 113 114 // fork father, wait next socket 115 if ($pid > 0) { 116 // kill previous zombie 117 /** @noinspection PhpStatementHasEmptyBodyInspection */ 118 while (pcntl_wait($status, WNOHANG) > 0) { 119 } 120 continue; 121 } 122 123 $this->handleGeminiConnection($pid, $conn, $peername); 124 } 125 } 126 127 /** 128 * Handles a single Gemini Request 129 * 130 * @param int $pid process ID, forked children are 0 and will exit after handling 131 * @param resource $conn The connected socket 132 * @param string $peername The connected peer 133 * @return void 134 */ 135 protected function handleGeminiConnection($pid, $conn, $peername) 136 { 137 $tlsSuccess = stream_socket_enable_crypto($conn, true, STREAM_CRYPTO_METHOD_TLS_SERVER); 138 if ($tlsSuccess !== true) { 139 fclose($conn); 140 $this->warning('TLS failed for connection from {peername}', compact('peername')); 141 142 // forked child or single thread? 143 if ($pid === 0) exit; 144 return; 145 } 146 147 $req = stream_get_line($conn, 1024, "\n"); 148 $this->info(date('Y-m-d H:i:s') . "\t" . $peername . "\t" . trim($req)); 149 150 $url_elems = parse_url(trim($req)); 151 if (empty($url_elems['path'])) { 152 $url_elems['path'] = '/'; 153 } 154 $url_elems['path'] = str_replace("\\", '/', rawurldecode($url_elems['path'])); 155 156 $response = false; 157 $body = false; 158 // check scheme 159 if ($response === false && $url_elems['scheme'] != 'gemini') { 160 $response = "59 BAD PROTOCOL\r\n"; 161 } 162 163 // check path 164 if ($response === false && strpos($url_elems['path'], '/..') !== false) { 165 $response = "59 BAD URL\r\n"; 166 } 167 168 // environment 169 global $_SERVER; 170 $_SERVER['HTTP_HOST'] = $url_elems['host']; 171 $_SERVER['SERVER_NAME'] = $url_elems['host']; 172 $_SERVER['REMOTE_ADDR'] = explode(':', $peername)[0]; 173 $_SERVER['REMOTE_PORT'] = explode(':', $peername)[1]; 174 $_SERVER['REQUEST_URI'] = $url_elems['path']; 175 176 $answer = $this->generateResponse($url_elems['path']); 177 if ($answer) { 178 $response = "20 " . $answer['mime'] . "\r\n"; 179 $body = $answer['body']; 180 } 181 182 if ($response === false) { 183 $response = "51 NOT FOUND\r\n"; 184 } 185 186 fputs($conn, $response); 187 if ($body !== false) { 188 fputs($conn, $body); 189 } 190 fflush($conn); 191 fclose($conn); 192 193 // forked child 194 if ($pid == 0) exit; 195 } 196 197 /** 198 * Generates the response to send 199 */ 200 protected function generateResponse($path) 201 { 202 if (substr($path, 0, 8) == '/_media/') { 203 $isMedia = true; 204 $path = substr($path, 8); 205 } else { 206 $isMedia = false; 207 } 208 $id = cleanID(str_replace('/', ':', $path)); 209 if(auth_quickaclcheck($id) < AUTH_READ) return false; 210 211 if ($isMedia) { 212 return $this->generateMediaResponse($id); 213 } else { 214 return $this->generatePageResponse($id); 215 } 216 } 217 218 /** 219 * Serve the given media ID raw 220 * 221 * @param string $id 222 * @return array|false 223 * @todo Streaming the data would be better 224 * 225 */ 226 protected function generateMediaResponse($id) 227 { 228 if (!media_exists($id)) return false; 229 $file = mediaFN($id); 230 list($ext, $mime, $dl) = mimetype($file); 231 232 return [ 233 'mime' => $mime, 234 'body' => file_get_contents($file), 235 ]; 236 } 237 238 /** 239 * Serve the given page ID as gemtext 240 * 241 * @param string $id 242 * @return array|false 243 */ 244 protected function generatePageResponse($id) 245 { 246 if (!page_exists($id)) return false; 247 248 global $ID; 249 global $INFO; 250 251 // FIXME we probably need to provide more standard environment here 252 $ID = $id; 253 $INFO = pageinfo(); 254 $file = wikiFN($ID); 255 256 return [ 257 'mime' => 'text/gemini; lang=en', 258 'body' => p_cached_output($file, 'gemini', $ID), 259 ]; 260 } 261 262 /** 263 * Create and cache a certificate for the given domain 264 * 265 * @param string $domain 266 * @return string 267 */ 268 protected function getSelfSignedCertificate($domain) 269 { 270 271 $pemfile = getCacheName($domain, '.pem'); 272 if (time() - filemtime($pemfile) > 3620 * 60 * 60 * 24) { 273 $this->info('Generating new certificate for {domain}', compact('domain')); 274 $pem = $this->createCert($domain); 275 file_put_contents($pemfile, $pem); 276 } 277 278 return $pemfile; 279 } 280 281 /** 282 * Create a simple, self-signed SSL certificate 283 * 284 * @param string $cn Common name 285 * @return string PEM 286 */ 287 protected function createCert($cn) 288 { 289 $days = 3650; 290 $out = [ 291 'public' => '', 292 'private' => '', 293 ]; 294 295 $config = [ 296 'digest_alg' => 'AES-128-CBC', 297 'private_key_bits' => 4096, 298 'private_key_type' => OPENSSL_KEYTYPE_RSA, 299 'encrypt_key' => false, 300 ]; 301 302 $dn = [ 303 'commonName' => $cn, 304 'organizationName' => 'DokuWiki', 305 'emailAddress' => 'admin@example.com', 306 ]; 307 308 $privkey = openssl_pkey_new($config); 309 $csr = openssl_csr_new($dn, $privkey, $config); 310 $cert = openssl_csr_sign($csr, null, $privkey, $days, $config, 0); 311 openssl_x509_export($cert, $out['public']); 312 openssl_pkey_export($privkey, $out['private']); 313 openssl_pkey_free($privkey); 314 315 return $out['public'] . $out['private']; 316 } 317 318} 319 320