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