chiark / gitweb /
really: Document need to be in the "root" group as well. (This is better than removi...
[chiark-utils.git] / scripts / git-cache-proxy
1 #!/usr/bin/perl -w
2 #
3 # git caching proxy
4
5 # usage: run it on some port, and then clone or fetch
6 #  "git://<realhost>:<realport>/<real-git-url>[ <options>]"
7 # where <real-git-url> is http://<host>/... or git://<host>/...
8 # and <options> is zero or more (whitespace-separated) of
9 #    [<some-option>]      will be ignored if not recognised
10 #    {<some-option>}      error if not recognised
11 # options currently known:
12 #    fetch=must           fail if the fetch/clone from upstream fails
13 #    fetch=no             just use what is in the cache
14 #    fetch=try            use what is in the cache if the fetch/clone fails
15 #    timeout=<seconds>    length of time to allow for fetch/clone
16
17 # example inetd.conf line:
18 #  9419 stream tcp nowait git-cache /usr/bin/git-cache-proxy git-cache-proxy
19 # you'll need to 
20 #  adduser git-cache
21 #  mkdir /var/cache/git-cache-proxy
22 #  chown git-cache /var/cache/git-cache-proxy
23
24 # git-cache-proxy
25 # Copyright 2010 Tony Finch
26 # Copyright 2013 Ian Jackson
27
28 # git-cache-proxy is free software; you can redistribute it and/or
29 # modify them under the terms of the GNU General Public License as
30 # published by the Free Software Foundation; either version 3, or (at
31 # your option) any later version.
32 #
33 # git-cache-proxy is distributed in the hope that it will be useful,
34 # but WITHOUT ANY WARRANTY; without even the implied warranty of
35 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
36 # General Public License for more details.
37
38 # You should have received a copy of the GNU General Public License along
39 # with this program; if not, consult the Free Software Foundation's
40 # website at www.fsf.org, or the GNU Project website at www.gnu.org.
41
42 # (Some code taken from userv-utils's git-daemon.in and git-service.in
43 # which were written by Tony Finch <dot@dotat.at> and subsequently
44 # heavily modified by Ian Jackson <ijackson@chiark.greenend.org.uk>
45 # and were released under CC0 1.0.  The whole program is now GPLv3+.)
46
47 use strict;
48 use warnings;
49
50 use POSIX;
51 use Socket;
52 use Sys::Syslog;
53 use Fcntl qw(:flock SEEK_SET);
54 use File::Path qw(remove_tree);
55
56 our $us = 'git-cache-proxy';
57
58 our $debug = 0;
59 our $housekeepingeverydays = 1;
60 our $treeexpiredays = 21;
61 our $fetchtimeout = 1800;
62 our $maxfetchtimeout = 3600;
63 our $cachedir = '/var/cache/git-cache-proxy';
64 our $housekeepingonly = 0;
65
66 #---------- error handling and logging ----------
67
68 # This is a bit fiddly, because we want to catch errors sent to stderr
69 # and dump them to syslog if we can, but only if we are running as an
70 # inetd service.
71
72 our $log; # filehandle (ref), or "1" meaning syslog
73
74 sub ntoa {
75     my $sockaddr = shift;
76     return ('(local)') unless defined $sockaddr;
77     my ($port,$addr) = sockaddr_in $sockaddr;
78     $addr = inet_ntoa $addr;
79     return ("[$addr]:$port",$addr,$port);
80 }
81
82 our ($client) = ntoa getpeername STDIN;
83 our ($server) = ntoa getsockname STDIN;
84
85 sub ensurelog () {
86     return if $log;
87     openlog $us, qw(pid), 'daemon';
88     $log = 1;
89 }
90
91 sub logm ($$) {
92     my ($pri, $msg) = @_;
93     return if $pri eq 'debug' && !$debug;
94     if ($client eq '(local)') {
95         print STDERR "$us: $pri: $msg\n" or die $!;
96         return;
97     }
98     ensurelog();
99     my $mainmsg = sprintf "%s-%s: %s", $server, $client, $msg;
100     if (ref $log) {
101         my $wholemsg = sprintf("%s [%d] %s: %s\n",
102                                strftime("%Y-%m-%d %H:%M:%S Z", gmtime),
103                                $$,
104                                $pri eq 'err' ? 'error' : $pri,
105                                $mainmsg);
106         print $log $wholemsg;
107     } else {
108         syslog $pri, "%s", "$pri $mainmsg";
109     }
110 }
111
112 if ($client ne '(local)') {
113     open STDERR, ">/dev/null" or exit 255;
114     open TEMPERR, "+>", undef or exit 255;
115     open STDERR, ">&TEMPERR" or exit 255;
116 }
117
118 END {
119     if ($client ne '(local)') {
120         if ($?) { logm 'crit', "crashing ($?)"; }
121         seek TEMPERR, 0, SEEK_SET;
122         while (<TEMPERR>) {
123             chomp;
124             logm 'crit', $_;
125         }
126     }
127     exit $?;
128 }
129
130 sub fail ($) {
131     my ($msg) = @_;
132     logm 'err', $msg;
133     exit 0;
134 }
135
136 sub gitfail ($) {
137     my ($msg) = @_;
138     close LOCK;
139     alarm 60;
140     logm 'notice', $msg;
141     my $gitmsg = "ERR $us: $msg";
142     $gitmsg = substr($gitmsg,0,65535); # just in case
143     printf "%04x%s", length($gitmsg)+4, $gitmsg;
144     flush STDOUT;
145     exit 0;
146 }
147
148 #---------- argument parsing ----------
149
150 for (;;) {
151     last unless @ARGV;
152     last unless $ARGV[0] =~ m/^-/;
153     $_ = shift @ARGV;
154     for (;;) {
155         last unless m/^-./;
156         if (s/^-H/-/) {
157             $housekeepingonly++;
158         } elsif (s/^-D/-/) {
159             $debug++;
160         } elsif (s/^-L(.*)$//) {
161             my $logfile = $_;
162             open STDERR, ">>", $logfile or fail "open $logfile: $!";
163             $log = \*STDERR;
164         } elsif (s/^-d(.*)$//) {
165             $cachedir = $1;
166         } elsif (s/^--( max-fetch-timeout
167                       | fetch-timeout
168                       | tree-expire-days
169                       | housekeeping-interval-days
170                       )=(\d+)$//x) {
171             my $vn = $1;
172             $vn =~ y/-//d;
173             die $vn unless defined ${ $::{$vn} };
174             ${ $::{$vn} } = $2;
175         } else {
176             fail "bad usage: unknown option `$_'";
177         }
178     }
179 }
180
181 !@ARGV or fail "bad usage: no non-option arguments permitted";
182
183 #---------- utility functions ----------
184
185 sub lockfile ($$$) {
186     my ($fh, $fn, $flockmode) = @_;
187     my $what = $fn.(($flockmode & ~LOCK_NB) == LOCK_SH ? " (shared)" : "");
188     for (;;) {
189         close $fh;
190         open $fh, '+>', $fn or fail "open/create $fn for lock: $!";
191         logm 'debug', "lock $what: acquiring";
192         if (!flock $fh, $flockmode) {
193             if ($flockmode & LOCK_NB && $! == EWOULDBLOCK) {
194                 return 0; # ok then
195             }
196             fail "lock $what: $!";
197         }
198         stat $fh or fail "stat opened $fn: $!";
199         my $fh_ino = ((stat _)[1]);
200         if (!stat $fn) {
201             $! == ENOENT or fail "stat $fn: $!";
202             next;
203         }
204         my $fn_ino = ((stat _)[1]);
205         if ($fn_ino == $fh_ino) {
206             logm 'debug', "lock $what: acquired";
207             return 1;
208         }
209         logm 'debug', "lock $what: deleted, need to loop again";
210         # oh dear
211     }
212 }
213
214 sub xread {
215     my $length = shift;
216     my $buffer = "";
217     while ($length > length $buffer) {
218         my $ret = sysread STDIN, $buffer, $length, length $buffer;
219         fail "expected $length bytes, got ".length $buffer
220                             if defined $ret and $ret == 0;
221         fail "read: $!" if not defined $ret and $! != EINTR and $! != EAGAIN;
222     }
223     return $buffer;
224 }
225
226 #---------- main program ----------
227
228 chdir $cachedir or fail "chdir $cachedir: $!";
229
230 our ($service,$specpath,$spechost,$subdir);
231 our ($tmpd,$gitd,$lock);
232 our ($fetch,$url);
233
234 sub servinfo ($) {
235     my ($msg) = @_;
236     logm 'info', "service `$specpath': $msg";
237 }
238
239 sub readcommand () {
240     $SIG{ALRM} = sub { fail "timeout" };
241     alarm 30;
242
243     my $hex_len = xread 4;
244     fail "Bad hex in packet length" unless $hex_len =~ m|^[0-9a-fA-F]{4}$|;
245     my $line = xread -4 + hex $hex_len;
246     unless (($service,$specpath,$spechost) = $line =~
247             m|^(git-[a-z-]+) /*([!-~ ]+)\0host=([!-~]+)\0$|) {
248         $line =~ s|[^ -~]+| |g;
249         gitfail "unknown/unsupported instruction `$line'"
250     }
251
252     alarm 0;
253
254     $service eq 'git-upload-pack'
255         or gitfail "unknown/unsupported service `$service'";
256
257     $fetch = 2; # 0:don't; 1:try; 2:force
258     $url = $specpath;
259
260     while ($url =~ s#\s+(\[)([^][{}]+)\]$## ||
261            $url =~ s#\s+(\{)([^][{}]+)\}$##) {
262         $_ = $2;
263         my $must = $1 eq '{';
264         if (m/^fetch=try$/) {
265             $fetch = 1;
266         } elsif (m/^fetch=no$/) {
267             $fetch = 0;
268         } elsif (m/^fetch=must$/) {
269             $fetch = 2; # the default
270         } elsif (m/^timeout=(\d+)$/ && $1 >= 1) {
271             $fetchtimeout = $1 <= $maxfetchtimeout ? $1 : $maxfetchtimeout;
272         } elsif ($must) {
273             gitfail "unknown/unsupported option `$_'";
274         }
275     }
276
277     $url =~ m{^(?:https?|git)://[-.0-9a-z]+/}
278         or gitfail "unknown/unsupported url scheme or format `$url'";
279
280     $subdir = $url;
281     $subdir =~ s|\\|\\\\|g;
282     $subdir =~ s|,|\\,|g;
283     $subdir =~ s|/|,|g;
284
285     $tmpd= "$subdir\\.tmp";
286     $gitd= "$subdir\\.git";
287     $lock = "$subdir\\.lock";
288
289     servinfo "locking";
290 }
291
292 sub clonefetch () {
293     lockfile \*LOCK, $lock, LOCK_EX;
294
295     my $exists = lstat $gitd;
296     $exists or $!==ENOENT or fail "lstat $gitd: $!";
297
298     our $fetchfail = '';
299
300     if ($fetch) {
301
302         our @cmd;
303
304         if (!$exists) {
305             system qw(rm -rf --), $tmpd;
306             @cmd = (qw(git clone -q --mirror), $url, $tmpd);
307             servinfo "cloning";
308         } else {
309             @cmd = (qw(git remote update --prune));
310             servinfo "fetching";
311         }
312         my $cmd = "@cmd[0..1]";
313
314         my $child = open FETCHERR, "-|";
315         defined $child or fail "fork: $!";
316         if (!$child) {
317             if ($exists) {
318                 chdir $gitd or fail "chdir $gitd: $!";
319             }
320             setpgrp or fail "setpgrp: $!";
321             open STDERR, ">&STDOUT" or fail "redirect stderr: $!";
322             exec @cmd or fail "exec $cmd[0]: $!";
323         }
324
325         my $fetcherr = '';
326         my $timedout = 0;
327         {
328             local $SIG{ALRM} = sub {
329                 servinfo "fetch/clone timeout";
330                 $timedout=1; kill 9, -$child;
331             };
332             alarm($fetchtimeout);
333             $!=0; { local $/=undef; $fetcherr = <FETCHERR>; }
334             !FETCHERR->error or fail "read pipe from fetch/clone: $!";
335             alarm(10);
336         }
337
338         kill -9, $child or fail "kill fetch/clone: $!";
339         $!=0; $?=0; if (!close FETCHERR) {
340             fail "reap fetch/clone: $!" if $!;
341             my $fetchfail =
342                 !($? & 255) ? "$cmd died with error exit code ".($? >> 8) :
343                 $? != 9 ? "$cmd died due to fatal signa, status $?" :
344                 $timedout ? "$cmd timed out (${fetchtimeout}s)" :
345                 "$cmd died due to unexpected SIGKILL";
346             if (length $fetcherr) {
347                 $fetchfail .= "\n$fetcherr";
348                 $fetchfail =~ s/\n$//;
349                 $fetchfail =~ s{\n}{ // }g;
350             }
351             if ($fetch >= 2) {
352                 gitfail $fetchfail;
353             } else {
354                 servinfo "fetch/clone failed: $fetchfail";
355             }
356         }
357
358         if (!$exists) {
359             rename $tmpd, $gitd or fail "rename fresh $tmpd to $gitd: $!";
360             $exists = 1;
361         }
362     } else {
363         $fetchfail = 'not attempted';
364     }
365
366     if (!$exists) {
367         gitfail "no cached data, and not cloned: $fetchfail";
368     }
369
370     servinfo "sharing";
371     lockfile \*LOCK, $lock, LOCK_SH; # NB releases and relocks
372
373     if (stat $gitd) {
374         return 1;
375     }
376     $!==ENOENT or fail "stat $gitd: $!";
377
378     # Well, err, someone must have taken the lock in between
379     # and garbage collected it.  How annoying.
380     return 0;
381 }
382
383 sub hkfail ($) { my ($msg) = @_; fail "housekeeping: $msg"; }
384
385 sub housekeeping () {
386     logm 'info', "housekeeping started";
387     foreach $lock (<[a-z]*\\.lock>) {
388         my $subdir = $lock;  $subdir =~ s/\\.lock$//;
389         if (!lstat $lock) {
390             $! == ENOENT or hkfail "$lock: lstat: $!";
391             next;
392         }
393         if (-M _ <= $treeexpiredays) {
394             logm 'debug', "housekeeping: subdirs $subdir: touched recently";
395             next;
396         }
397         if (!lockfile \*LOCK, $lock, LOCK_EX|LOCK_NB) {
398             logm 'info', "housekeeping: subdirs $subdir: lock busy, skipping";
399             next;
400         }
401         logm 'info', "housekeeping: subdirs $subdir: cleaning";
402         my $ok = 1;
403         foreach my $suffix (qw(tmp git)) {
404             my $dir = "${subdir}\\.$suffix";
405             my $errs;
406             remove_tree($dir, { safe=>1, error=>\$errs });
407             if (stat $dir) {
408                 $ok = 0;
409                 logm 'warning', "housekeeping: $dir: problems with".
410                     "deletion prevent cleanup:";
411                 foreach my $err (@$errs) {
412                     logm 'info', "problem deleting: $err->[0]: $err->[1]";
413                 }
414             }
415         }
416         if ($ok) {
417             unlink $lock or hkfail "remove $lock: $!";
418         }
419     }
420     open HS, ">", "Housekeeping.stamp" or hkfail "touch Housekeeping.stamp: $!";
421     close HS or hkfail "close Housekeeping.stamp: $!";
422     logm 'info', "housekeeping finished";
423 }
424
425 sub housekeepingcheck ($$) {
426     my ($dofork, $force) = @_;
427     if (!$force) {
428         if (!lockfile \*HLOCK, "Housekeeping.lock", LOCK_EX|LOCK_NB) {
429             logm 'debug', "housekeeping lock taken, not running";
430             close HLOCK;
431             return 0;
432         }
433     }
434     if ($force) {
435         logm 'info', "housekeeping forced";
436     } elsif (!lstat "Housekeeping.stamp") {
437         $! == ENOENT or fail "lstat Housekeeping.stamp: $!";
438         logm 'info', "housekeeping not done yet, will run";
439     } elsif (-M _ <= $housekeepingeverydays) {
440         logm 'debug', "housekeeping done recently";
441         close HLOCK;
442         return 0;
443     }
444     if ($dofork) {
445         my $child = fork;
446         defined $child or hkfail "fork: $!";
447         if (!$child) {
448             housekeeping();
449             exit 0;
450         }
451     } else {
452         housekeeping();
453     }
454     close HLOCK;
455     return 1;
456 }
457
458 sub runcommand () {
459     servinfo "serving";
460
461     chdir $gitd or fail "chdir $gitd: $!";
462
463     exec qw(git-upload-pack --strict --timeout=1000 .)
464         or fail "exec git-upload-pack: $!";
465 }
466
467 sub daemonservice () {
468     readcommand();
469     while (!clonefetch()) { }
470     housekeepingcheck(1,0);
471     runcommand();
472 }
473
474 if ($housekeepingonly) {
475     housekeepingcheck(0, $housekeepingonly>=2);
476 } else {
477     daemonservice();
478 }