SquidPurgeClient.php 9.8 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. * @todo Consider using MultiHttpClient.
  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. */
  60. public function __construct( $server ) {
  61. $parts = explode( ':', $server, 2 );
  62. $this->host = $parts[0];
  63. $this->port = $parts[1] ?? 80;
  64. }
  65. /**
  66. * Open a socket if there isn't one open already, return it.
  67. * Returns false on error.
  68. *
  69. * @return bool|resource
  70. */
  71. protected function getSocket() {
  72. if ( $this->socket !== null ) {
  73. return $this->socket;
  74. }
  75. $ip = $this->getIP();
  76. if ( !$ip ) {
  77. $this->log( "DNS error" );
  78. $this->markDown();
  79. return false;
  80. }
  81. $this->socket = socket_create( AF_INET, SOCK_STREAM, SOL_TCP );
  82. socket_set_nonblock( $this->socket );
  83. Wikimedia\suppressWarnings();
  84. $ok = socket_connect( $this->socket, $ip, $this->port );
  85. Wikimedia\restoreWarnings();
  86. if ( !$ok ) {
  87. $error = socket_last_error( $this->socket );
  88. if ( $error !== self::EINPROGRESS ) {
  89. $this->log( "connection error: " . socket_strerror( $error ) );
  90. $this->markDown();
  91. return false;
  92. }
  93. }
  94. return $this->socket;
  95. }
  96. /**
  97. * Get read socket array for select()
  98. * @return array
  99. */
  100. public function getReadSocketsForSelect() {
  101. if ( $this->readState == 'idle' ) {
  102. return [];
  103. }
  104. $socket = $this->getSocket();
  105. if ( $socket === false ) {
  106. return [];
  107. }
  108. return [ $socket ];
  109. }
  110. /**
  111. * Get write socket array for select()
  112. * @return array
  113. */
  114. public function getWriteSocketsForSelect() {
  115. if ( !strlen( $this->writeBuffer ) ) {
  116. return [];
  117. }
  118. $socket = $this->getSocket();
  119. if ( $socket === false ) {
  120. return [];
  121. }
  122. return [ $socket ];
  123. }
  124. /**
  125. * Get the host's IP address.
  126. * Does not support IPv6 at present due to the lack of a convenient interface in PHP.
  127. * @throws MWException
  128. * @return string
  129. */
  130. protected function getIP() {
  131. if ( $this->ip === null ) {
  132. if ( IP::isIPv4( $this->host ) ) {
  133. $this->ip = $this->host;
  134. } elseif ( IP::isIPv6( $this->host ) ) {
  135. throw new MWException( '$wgCdnServers does not support IPv6' );
  136. } else {
  137. Wikimedia\suppressWarnings();
  138. $this->ip = gethostbyname( $this->host );
  139. if ( $this->ip === $this->host ) {
  140. $this->ip = false;
  141. }
  142. Wikimedia\restoreWarnings();
  143. }
  144. }
  145. return $this->ip;
  146. }
  147. /**
  148. * Close the socket and ignore any future purge requests.
  149. * This is called if there is a protocol error.
  150. */
  151. protected function markDown() {
  152. $this->close();
  153. $this->socket = false;
  154. }
  155. /**
  156. * Close the socket but allow it to be reopened for future purge requests
  157. */
  158. public function close() {
  159. if ( $this->socket ) {
  160. Wikimedia\suppressWarnings();
  161. socket_set_block( $this->socket );
  162. socket_shutdown( $this->socket );
  163. socket_close( $this->socket );
  164. Wikimedia\restoreWarnings();
  165. }
  166. $this->socket = null;
  167. $this->readBuffer = '';
  168. // Write buffer is kept since it may contain a request for the next socket
  169. }
  170. /**
  171. * Queue a purge operation
  172. *
  173. * @param string $url Fully expanded URL (with host and protocol)
  174. */
  175. public function queuePurge( $url ) {
  176. global $wgSquidPurgeUseHostHeader;
  177. $url = str_replace( "\n", '', $url ); // sanity
  178. $request = [];
  179. if ( $wgSquidPurgeUseHostHeader ) {
  180. $url = wfParseUrl( $url );
  181. $host = $url['host'];
  182. if ( isset( $url['port'] ) && strlen( $url['port'] ) > 0 ) {
  183. $host .= ":" . $url['port'];
  184. }
  185. $path = $url['path'];
  186. if ( isset( $url['query'] ) && is_string( $url['query'] ) ) {
  187. $path = wfAppendQuery( $path, $url['query'] );
  188. }
  189. $request[] = "PURGE $path HTTP/1.1";
  190. $request[] = "Host: $host";
  191. } else {
  192. wfDeprecated( '$wgSquidPurgeUseHostHeader = false', '1.33' );
  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 {
  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. }