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