Merge "logging: Start using LinkTarget & UserIdentity in ManualLogEntry"
[lhc/web/wiklou.git] / includes / clientpool / SquidPurgeClient.php
1 <?php
2 /**
3 * Squid and Varnish cache purging.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 */
22
23 /**
24 * An HTTP 1.0 client built for the purposes of purging Squid and Varnish.
25 * Uses asynchronous I/O, allowing purges to be done in a highly parallel
26 * manner.
27 *
28 * @todo Consider using MultiHttpClient.
29 */
30 class SquidPurgeClient {
31 /** @var string */
32 protected $host;
33
34 /** @var int */
35 protected $port;
36
37 /** @var string|bool */
38 protected $ip;
39
40 /** @var string */
41 protected $readState = 'idle';
42
43 /** @var string */
44 protected $writeBuffer = '';
45
46 /** @var array */
47 protected $requests = [];
48
49 /** @var mixed */
50 protected $currentRequestIndex;
51
52 const EINTR = 4;
53 const EAGAIN = 11;
54 const EINPROGRESS = 115;
55 const BUFFER_SIZE = 8192;
56
57 /**
58 * @var resource|null The socket resource, or null for unconnected, or false
59 * for disabled due to error.
60 */
61 protected $socket;
62
63 /** @var string */
64 protected $readBuffer;
65
66 /** @var int */
67 protected $bodyRemaining;
68
69 /**
70 * @param string $server
71 * @param array $options
72 */
73 public function __construct( $server, $options = [] ) {
74 $parts = explode( ':', $server, 2 );
75 $this->host = $parts[0];
76 $this->port = $parts[1] ?? 80;
77 }
78
79 /**
80 * Open a socket if there isn't one open already, return it.
81 * Returns false on error.
82 *
83 * @return bool|resource
84 */
85 protected function getSocket() {
86 if ( $this->socket !== null ) {
87 return $this->socket;
88 }
89
90 $ip = $this->getIP();
91 if ( !$ip ) {
92 $this->log( "DNS error" );
93 $this->markDown();
94 return false;
95 }
96 $this->socket = socket_create( AF_INET, SOCK_STREAM, SOL_TCP );
97 socket_set_nonblock( $this->socket );
98 Wikimedia\suppressWarnings();
99 $ok = socket_connect( $this->socket, $ip, $this->port );
100 Wikimedia\restoreWarnings();
101 if ( !$ok ) {
102 $error = socket_last_error( $this->socket );
103 if ( $error !== self::EINPROGRESS ) {
104 $this->log( "connection error: " . socket_strerror( $error ) );
105 $this->markDown();
106 return false;
107 }
108 }
109
110 return $this->socket;
111 }
112
113 /**
114 * Get read socket array for select()
115 * @return array
116 */
117 public function getReadSocketsForSelect() {
118 if ( $this->readState == 'idle' ) {
119 return [];
120 }
121 $socket = $this->getSocket();
122 if ( $socket === false ) {
123 return [];
124 }
125 return [ $socket ];
126 }
127
128 /**
129 * Get write socket array for select()
130 * @return array
131 */
132 public function getWriteSocketsForSelect() {
133 if ( !strlen( $this->writeBuffer ) ) {
134 return [];
135 }
136 $socket = $this->getSocket();
137 if ( $socket === false ) {
138 return [];
139 }
140 return [ $socket ];
141 }
142
143 /**
144 * Get the host's IP address.
145 * Does not support IPv6 at present due to the lack of a convenient interface in PHP.
146 * @throws MWException
147 * @return string
148 */
149 protected function getIP() {
150 if ( $this->ip === null ) {
151 if ( IP::isIPv4( $this->host ) ) {
152 $this->ip = $this->host;
153 } elseif ( IP::isIPv6( $this->host ) ) {
154 throw new MWException( '$wgSquidServers does not support IPv6' );
155 } else {
156 Wikimedia\suppressWarnings();
157 $this->ip = gethostbyname( $this->host );
158 if ( $this->ip === $this->host ) {
159 $this->ip = false;
160 }
161 Wikimedia\restoreWarnings();
162 }
163 }
164 return $this->ip;
165 }
166
167 /**
168 * Close the socket and ignore any future purge requests.
169 * This is called if there is a protocol error.
170 */
171 protected function markDown() {
172 $this->close();
173 $this->socket = false;
174 }
175
176 /**
177 * Close the socket but allow it to be reopened for future purge requests
178 */
179 public function close() {
180 if ( $this->socket ) {
181 Wikimedia\suppressWarnings();
182 socket_set_block( $this->socket );
183 socket_shutdown( $this->socket );
184 socket_close( $this->socket );
185 Wikimedia\restoreWarnings();
186 }
187 $this->socket = null;
188 $this->readBuffer = '';
189 // Write buffer is kept since it may contain a request for the next socket
190 }
191
192 /**
193 * Queue a purge operation
194 *
195 * @param string $url
196 */
197 public function queuePurge( $url ) {
198 global $wgSquidPurgeUseHostHeader;
199 $url = CdnCacheUpdate::expand( str_replace( "\n", '', $url ) );
200 $request = [];
201 if ( $wgSquidPurgeUseHostHeader ) {
202 $url = wfParseUrl( $url );
203 $host = $url['host'];
204 if ( isset( $url['port'] ) && strlen( $url['port'] ) > 0 ) {
205 $host .= ":" . $url['port'];
206 }
207 $path = $url['path'];
208 if ( isset( $url['query'] ) && is_string( $url['query'] ) ) {
209 $path = wfAppendQuery( $path, $url['query'] );
210 }
211 $request[] = "PURGE $path HTTP/1.1";
212 $request[] = "Host: $host";
213 } else {
214 wfDeprecated( '$wgSquidPurgeUseHostHeader = false', '1.33' );
215 $request[] = "PURGE $url HTTP/1.0";
216 }
217 $request[] = "Connection: Keep-Alive";
218 $request[] = "Proxy-Connection: Keep-Alive";
219 $request[] = "User-Agent: " . Http::userAgent() . ' ' . __CLASS__;
220 // Two ''s to create \r\n\r\n
221 $request[] = '';
222 $request[] = '';
223
224 $this->requests[] = implode( "\r\n", $request );
225 if ( $this->currentRequestIndex === null ) {
226 $this->nextRequest();
227 }
228 }
229
230 /**
231 * @return bool
232 */
233 public function isIdle() {
234 return strlen( $this->writeBuffer ) == 0 && $this->readState == 'idle';
235 }
236
237 /**
238 * Perform pending writes. Call this when socket_select() indicates that writing will not block.
239 */
240 public function doWrites() {
241 if ( !strlen( $this->writeBuffer ) ) {
242 return;
243 }
244 $socket = $this->getSocket();
245 if ( !$socket ) {
246 return;
247 }
248
249 if ( strlen( $this->writeBuffer ) <= self::BUFFER_SIZE ) {
250 $buf = $this->writeBuffer;
251 $flags = MSG_EOR;
252 } else {
253 $buf = substr( $this->writeBuffer, 0, self::BUFFER_SIZE );
254 $flags = 0;
255 }
256 Wikimedia\suppressWarnings();
257 $bytesSent = socket_send( $socket, $buf, strlen( $buf ), $flags );
258 Wikimedia\restoreWarnings();
259
260 if ( $bytesSent === false ) {
261 $error = socket_last_error( $socket );
262 if ( $error != self::EAGAIN && $error != self::EINTR ) {
263 $this->log( 'write error: ' . socket_strerror( $error ) );
264 $this->markDown();
265 }
266 return;
267 }
268
269 $this->writeBuffer = substr( $this->writeBuffer, $bytesSent );
270 }
271
272 /**
273 * Read some data. Call this when socket_select() indicates that the read buffer is non-empty.
274 */
275 public function doReads() {
276 $socket = $this->getSocket();
277 if ( !$socket ) {
278 return;
279 }
280
281 $buf = '';
282 Wikimedia\suppressWarnings();
283 $bytesRead = socket_recv( $socket, $buf, self::BUFFER_SIZE, 0 );
284 Wikimedia\restoreWarnings();
285 if ( $bytesRead === false ) {
286 $error = socket_last_error( $socket );
287 if ( $error != self::EAGAIN && $error != self::EINTR ) {
288 $this->log( 'read error: ' . socket_strerror( $error ) );
289 $this->markDown();
290 return;
291 }
292 } elseif ( $bytesRead === 0 ) {
293 // Assume EOF
294 $this->close();
295 return;
296 }
297
298 $this->readBuffer .= $buf;
299 while ( $this->socket && $this->processReadBuffer() === 'continue' );
300 }
301
302 /**
303 * @throws MWException
304 * @return string
305 */
306 protected function processReadBuffer() {
307 switch ( $this->readState ) {
308 case 'idle':
309 return 'done';
310 case 'status':
311 case 'header':
312 $lines = explode( "\r\n", $this->readBuffer, 2 );
313 if ( count( $lines ) < 2 ) {
314 return 'done';
315 }
316 if ( $this->readState == 'status' ) {
317 $this->processStatusLine( $lines[0] );
318 } else {
319 $this->processHeaderLine( $lines[0] );
320 }
321 $this->readBuffer = $lines[1];
322 return 'continue';
323 case 'body':
324 if ( $this->bodyRemaining !== null ) {
325 if ( $this->bodyRemaining > strlen( $this->readBuffer ) ) {
326 $this->bodyRemaining -= strlen( $this->readBuffer );
327 $this->readBuffer = '';
328 return 'done';
329 } else {
330 $this->readBuffer = substr( $this->readBuffer, $this->bodyRemaining );
331 $this->bodyRemaining = 0;
332 $this->nextRequest();
333 return 'continue';
334 }
335 } else {
336 // No content length, read all data to EOF
337 $this->readBuffer = '';
338 return 'done';
339 }
340 default:
341 throw new MWException( __METHOD__ . ': unexpected state' );
342 }
343 }
344
345 /**
346 * @param string $line
347 */
348 protected function processStatusLine( $line ) {
349 if ( !preg_match( '!^HTTP/(\d+)\.(\d+) (\d{3}) (.*)$!', $line, $m ) ) {
350 $this->log( 'invalid status line' );
351 $this->markDown();
352 return;
353 }
354 list( , , , $status, $reason ) = $m;
355 $status = intval( $status );
356 if ( $status !== 200 && $status !== 404 ) {
357 $this->log( "unexpected status code: $status $reason" );
358 $this->markDown();
359 return;
360 }
361 $this->readState = 'header';
362 }
363
364 /**
365 * @param string $line
366 */
367 protected function processHeaderLine( $line ) {
368 if ( preg_match( '/^Content-Length: (\d+)$/i', $line, $m ) ) {
369 $this->bodyRemaining = intval( $m[1] );
370 } elseif ( $line === '' ) {
371 $this->readState = 'body';
372 }
373 }
374
375 protected function nextRequest() {
376 if ( $this->currentRequestIndex !== null ) {
377 unset( $this->requests[$this->currentRequestIndex] );
378 }
379 if ( count( $this->requests ) ) {
380 $this->readState = 'status';
381 $this->currentRequestIndex = key( $this->requests );
382 $this->writeBuffer = $this->requests[$this->currentRequestIndex];
383 } else {
384 $this->readState = 'idle';
385 $this->currentRequestIndex = null;
386 $this->writeBuffer = '';
387 }
388 $this->bodyRemaining = null;
389 }
390
391 /**
392 * @param string $msg
393 */
394 protected function log( $msg ) {
395 wfDebugLog( 'squid', __CLASS__ . " ($this->host): $msg" );
396 }
397 }