Merge "maintenance: Script to rename titles for Unicode uppercasing changes"
[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 */
72 public function __construct( $server ) {
73 $parts = explode( ':', $server, 2 );
74 $this->host = $parts[0];
75 $this->port = $parts[1] ?? 80;
76 }
77
78 /**
79 * Open a socket if there isn't one open already, return it.
80 * Returns false on error.
81 *
82 * @return bool|resource
83 */
84 protected function getSocket() {
85 if ( $this->socket !== null ) {
86 return $this->socket;
87 }
88
89 $ip = $this->getIP();
90 if ( !$ip ) {
91 $this->log( "DNS error" );
92 $this->markDown();
93 return false;
94 }
95 $this->socket = socket_create( AF_INET, SOCK_STREAM, SOL_TCP );
96 socket_set_nonblock( $this->socket );
97 Wikimedia\suppressWarnings();
98 $ok = socket_connect( $this->socket, $ip, $this->port );
99 Wikimedia\restoreWarnings();
100 if ( !$ok ) {
101 $error = socket_last_error( $this->socket );
102 if ( $error !== self::EINPROGRESS ) {
103 $this->log( "connection error: " . socket_strerror( $error ) );
104 $this->markDown();
105 return false;
106 }
107 }
108
109 return $this->socket;
110 }
111
112 /**
113 * Get read socket array for select()
114 * @return array
115 */
116 public function getReadSocketsForSelect() {
117 if ( $this->readState == 'idle' ) {
118 return [];
119 }
120 $socket = $this->getSocket();
121 if ( $socket === false ) {
122 return [];
123 }
124 return [ $socket ];
125 }
126
127 /**
128 * Get write socket array for select()
129 * @return array
130 */
131 public function getWriteSocketsForSelect() {
132 if ( !strlen( $this->writeBuffer ) ) {
133 return [];
134 }
135 $socket = $this->getSocket();
136 if ( $socket === false ) {
137 return [];
138 }
139 return [ $socket ];
140 }
141
142 /**
143 * Get the host's IP address.
144 * Does not support IPv6 at present due to the lack of a convenient interface in PHP.
145 * @throws MWException
146 * @return string
147 */
148 protected function getIP() {
149 if ( $this->ip === null ) {
150 if ( IP::isIPv4( $this->host ) ) {
151 $this->ip = $this->host;
152 } elseif ( IP::isIPv6( $this->host ) ) {
153 throw new MWException( '$wgCdnServers does not support IPv6' );
154 } else {
155 Wikimedia\suppressWarnings();
156 $this->ip = gethostbyname( $this->host );
157 if ( $this->ip === $this->host ) {
158 $this->ip = false;
159 }
160 Wikimedia\restoreWarnings();
161 }
162 }
163 return $this->ip;
164 }
165
166 /**
167 * Close the socket and ignore any future purge requests.
168 * This is called if there is a protocol error.
169 */
170 protected function markDown() {
171 $this->close();
172 $this->socket = false;
173 }
174
175 /**
176 * Close the socket but allow it to be reopened for future purge requests
177 */
178 public function close() {
179 if ( $this->socket ) {
180 Wikimedia\suppressWarnings();
181 socket_set_block( $this->socket );
182 socket_shutdown( $this->socket );
183 socket_close( $this->socket );
184 Wikimedia\restoreWarnings();
185 }
186 $this->socket = null;
187 $this->readBuffer = '';
188 // Write buffer is kept since it may contain a request for the next socket
189 }
190
191 /**
192 * Queue a purge operation
193 *
194 * @param string $url
195 */
196 public function queuePurge( $url ) {
197 global $wgSquidPurgeUseHostHeader;
198 $url = CdnCacheUpdate::expand( str_replace( "\n", '', $url ) );
199 $request = [];
200 if ( $wgSquidPurgeUseHostHeader ) {
201 $url = wfParseUrl( $url );
202 $host = $url['host'];
203 if ( isset( $url['port'] ) && strlen( $url['port'] ) > 0 ) {
204 $host .= ":" . $url['port'];
205 }
206 $path = $url['path'];
207 if ( isset( $url['query'] ) && is_string( $url['query'] ) ) {
208 $path = wfAppendQuery( $path, $url['query'] );
209 }
210 $request[] = "PURGE $path HTTP/1.1";
211 $request[] = "Host: $host";
212 } else {
213 wfDeprecated( '$wgSquidPurgeUseHostHeader = false', '1.33' );
214 $request[] = "PURGE $url HTTP/1.0";
215 }
216 $request[] = "Connection: Keep-Alive";
217 $request[] = "Proxy-Connection: Keep-Alive";
218 $request[] = "User-Agent: " . Http::userAgent() . ' ' . __CLASS__;
219 // Two ''s to create \r\n\r\n
220 $request[] = '';
221 $request[] = '';
222
223 $this->requests[] = implode( "\r\n", $request );
224 if ( $this->currentRequestIndex === null ) {
225 $this->nextRequest();
226 }
227 }
228
229 /**
230 * @return bool
231 */
232 public function isIdle() {
233 return strlen( $this->writeBuffer ) == 0 && $this->readState == 'idle';
234 }
235
236 /**
237 * Perform pending writes. Call this when socket_select() indicates that writing will not block.
238 */
239 public function doWrites() {
240 if ( !strlen( $this->writeBuffer ) ) {
241 return;
242 }
243 $socket = $this->getSocket();
244 if ( !$socket ) {
245 return;
246 }
247
248 if ( strlen( $this->writeBuffer ) <= self::BUFFER_SIZE ) {
249 $buf = $this->writeBuffer;
250 $flags = MSG_EOR;
251 } else {
252 $buf = substr( $this->writeBuffer, 0, self::BUFFER_SIZE );
253 $flags = 0;
254 }
255 Wikimedia\suppressWarnings();
256 $bytesSent = socket_send( $socket, $buf, strlen( $buf ), $flags );
257 Wikimedia\restoreWarnings();
258
259 if ( $bytesSent === false ) {
260 $error = socket_last_error( $socket );
261 if ( $error != self::EAGAIN && $error != self::EINTR ) {
262 $this->log( 'write error: ' . socket_strerror( $error ) );
263 $this->markDown();
264 }
265 return;
266 }
267
268 $this->writeBuffer = substr( $this->writeBuffer, $bytesSent );
269 }
270
271 /**
272 * Read some data. Call this when socket_select() indicates that the read buffer is non-empty.
273 */
274 public function doReads() {
275 $socket = $this->getSocket();
276 if ( !$socket ) {
277 return;
278 }
279
280 $buf = '';
281 Wikimedia\suppressWarnings();
282 $bytesRead = socket_recv( $socket, $buf, self::BUFFER_SIZE, 0 );
283 Wikimedia\restoreWarnings();
284 if ( $bytesRead === false ) {
285 $error = socket_last_error( $socket );
286 if ( $error != self::EAGAIN && $error != self::EINTR ) {
287 $this->log( 'read error: ' . socket_strerror( $error ) );
288 $this->markDown();
289 return;
290 }
291 } elseif ( $bytesRead === 0 ) {
292 // Assume EOF
293 $this->close();
294 return;
295 }
296
297 $this->readBuffer .= $buf;
298 while ( $this->socket && $this->processReadBuffer() === 'continue' );
299 }
300
301 /**
302 * @throws MWException
303 * @return string
304 */
305 protected function processReadBuffer() {
306 switch ( $this->readState ) {
307 case 'idle':
308 return 'done';
309 case 'status':
310 case 'header':
311 $lines = explode( "\r\n", $this->readBuffer, 2 );
312 if ( count( $lines ) < 2 ) {
313 return 'done';
314 }
315 if ( $this->readState == 'status' ) {
316 $this->processStatusLine( $lines[0] );
317 } else {
318 $this->processHeaderLine( $lines[0] );
319 }
320 $this->readBuffer = $lines[1];
321 return 'continue';
322 case 'body':
323 if ( $this->bodyRemaining !== null ) {
324 if ( $this->bodyRemaining > strlen( $this->readBuffer ) ) {
325 $this->bodyRemaining -= strlen( $this->readBuffer );
326 $this->readBuffer = '';
327 return 'done';
328 } else {
329 $this->readBuffer = substr( $this->readBuffer, $this->bodyRemaining );
330 $this->bodyRemaining = 0;
331 $this->nextRequest();
332 return 'continue';
333 }
334 } else {
335 // No content length, read all data to EOF
336 $this->readBuffer = '';
337 return 'done';
338 }
339 default:
340 throw new MWException( __METHOD__ . ': unexpected state' );
341 }
342 }
343
344 /**
345 * @param string $line
346 */
347 protected function processStatusLine( $line ) {
348 if ( !preg_match( '!^HTTP/(\d+)\.(\d+) (\d{3}) (.*)$!', $line, $m ) ) {
349 $this->log( 'invalid status line' );
350 $this->markDown();
351 return;
352 }
353 list( , , , $status, $reason ) = $m;
354 $status = intval( $status );
355 if ( $status !== 200 && $status !== 404 ) {
356 $this->log( "unexpected status code: $status $reason" );
357 $this->markDown();
358 return;
359 }
360 $this->readState = 'header';
361 }
362
363 /**
364 * @param string $line
365 */
366 protected function processHeaderLine( $line ) {
367 if ( preg_match( '/^Content-Length: (\d+)$/i', $line, $m ) ) {
368 $this->bodyRemaining = intval( $m[1] );
369 } elseif ( $line === '' ) {
370 $this->readState = 'body';
371 }
372 }
373
374 protected function nextRequest() {
375 if ( $this->currentRequestIndex !== null ) {
376 unset( $this->requests[$this->currentRequestIndex] );
377 }
378 if ( count( $this->requests ) ) {
379 $this->readState = 'status';
380 $this->currentRequestIndex = key( $this->requests );
381 $this->writeBuffer = $this->requests[$this->currentRequestIndex];
382 } else {
383 $this->readState = 'idle';
384 $this->currentRequestIndex = null;
385 $this->writeBuffer = '';
386 }
387 $this->bodyRemaining = null;
388 }
389
390 /**
391 * @param string $msg
392 */
393 protected function log( $msg ) {
394 wfDebugLog( 'squid', __CLASS__ . " ($this->host): $msg" );
395 }
396 }