SquidPurgeClient.php 9.7 KB

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