chiark / gitweb /
dgit-repos-server: wip
[dgit.git] / dgit-repos-server
1 #!/usr/bin/perl -w
2 # dgit-repos-push-receiver
3 #
4 # usages:
5 #  .../dgit-repos-push-receiver KEYRING-AUTH-SPEC DGIT-REPOS-DIR --ssh
6 #  .../dgit-repos-push-receiver KEYRING-AUTH-SPEC DGIT-REPOS-DIR PACKAGE
7 # internal usage:
8 #  .../dgit-repos-push-receiver --pre-receive-hook PACKAGE
9 #
10 # Invoked as the ssh restricted command
11 #
12 # Works like git-receive-pack
13 #
14 # KEYRING-AUTH-SPEC is a :-separated list of
15 #   KEYRING.GPG,AUTH-SPEC
16 # where AUTH-SPEC is one of
17 #   a
18 #   mDM.TXT
19
20 use strict;
21
22 # What we do is this:
23 #  - extract the destination repo name somehow
24 #  - make a hardlink clone of the destination repo
25 #  - provide the destination with a stunt pre-receive hook
26 #  - run actual git-receive-pack with that new destination
27 #   as a result of this the stunt pre-receive hook runs; it does this
28 #     find the keyring(s) to use for verification
29 #     verify the signed tag
30 #     check that the signed tag has a suitable name
31 #     parse the signed tag body to extract the intended
32 #       distro and suite
33 #     check that the distro is right
34 #     check that the suite is the same as the branch we are
35 #       supposed to update
36 #     check that the signed tag refers to the same commit
37 #       as the new suite
38 #     check that the signer was correct
39 #     push the signed tag to the actual repo
40 #     push the new dgit branch head to the actual repo
41
42 use POSIX;
43 use Fcntl qw(:flock);
44
45 our $package_re = '[0-9a-z][-+.0-9a-z]+';
46
47 our $dgitrepos;
48 our $pkg;
49 our $destrepo;
50 our $workrepo;
51 our @keyrings;
52
53 sub acquirelock ($$) {
54     my ($lock, $must) = @_;
55     for (;;) {
56         my $fh = new IO::File, ">", $lock or die "open $lock: $!";
57         my $ok = flock $fh, $must ? LOCK_EX : (LOCK_EX|LOCK_NB);
58         if (!$ok) {
59             return unless $must;
60             die "flock $lock: $!";
61         }
62         if (!stat $lock) {
63             next if $! == ENOENT;
64             die "stat $lock: $!";
65         }
66         my $want = (stat _)[1];
67         stat $fh or die $!;
68         my $got = (stat _)[1];
69         return $fh if $got == $want;
70     }
71 }
72
73 sub makeworkingclone () {
74     $workrepo = "$dgitrepos/_tmp/${pkg}_incoming$$";
75     my $lock = "$workrepo.lock";
76     my $lockfh = acquirelock($lock, 1);
77     if (!stat $destrepo) {
78         $! == ENOENT or die "stat dest repo $destrepo: $!";
79         mkdir $workrepo or die "create work repo $workrepo: $!";
80         runcmd qw(git init --bare), $workrepo;
81     } else {
82         runcmd qw(git clone -l -q --mirror), $destrepo, $workrepo;
83     }
84 }
85
86 sub setupstunthook () {
87     my $prerecv = "$workrepo/hooks/pre-receive";
88     my $fh = new IO::File, $prerecv, O_WRONLY|O_CREAT|O_TRUNC, 0777
89         or die "$prerecv: $!";
90     print $fh <<END or die "$prerecv: $!";
91 #!/bin/sh
92 set -e
93 exec $0 --pre-receive-hook $pkg
94 END
95     close $fh or die "$prerecv: $!";
96     $ENV{'DGIT_RPR_WORK'}= $workrepo;
97     $ENV{'DGIT_RPR_DEST'}= $destrepo;
98 }
99
100 #----- stunt post-receive hook -----
101
102 our ($tagname, $tagval, $suite, $oldcommit, $commit);
103 our ($version, %tagh);
104
105 sub readupdates () {
106     while (<STDIN>) {
107         m/^(\S+) (\S+) (\S+)$/ or die "$_ ?";
108         my ($old, $sha1, $refname) = ($1, $2, $3);
109         if ($refname =~ m{^refs/tags/(?=debian/)}) {
110             die if defined $tagname;
111             $tagname = $'; #';
112             $tagval = $sha1;
113             reject "tag $tagname already exists -".
114                 " not replacing previously-pushed version"
115                 if $old =~ m/[^0]/;
116         } elsif ($refname =~ m{^refs/dgit/}) {
117             die if defined $suite;
118             $suite = $'; #';
119             $oldcommit = $old;
120             $commit = $sha1;
121         } else {
122             die;
123         }
124     }
125     STDIN->error and die $!;
126
127     die unless defined $refname;
128     die unless defined $branchname;
129 }
130
131 sub parsetag () {
132     open PT, ">dgit-tmp/plaintext" or die $!;
133     open DS, ">dgit-tmp/plaintext.asc" or die $!;
134     open T, "-|", qw(git cat-file tag), $tagval or die $!;
135     my %tagh;
136     for (;;) {
137         $!=0; $_=<T>; defined or die $!;
138         print PT or die $!;
139         if (m/^(\S+) (.*)/) {
140             push @{ $tagh{$1} }, $2;
141         } elsif (!m/\S/) {
142             last;
143         } else {
144             die;
145         }
146     }
147     $!=0; $_=<T>; defined or die $!;
148     m/^($package_re) release (\S+) for (\S+) \[dgit\]$/ or die;
149
150     die unless $1 eq $pkg;
151     $version = $2;
152     die unless $3 eq $suite;
153
154     for (;;) {
155         print PT or die $!;
156         $!=0; $_=<T>; defined or die $!;
157         last if m/^-----BEGIN PGP/;
158     }
159     for (;;) {
160         print DS or die $!;
161         $!=0; $_=<T>;
162         last if !defined;
163     }
164     T->error and die $!;
165     close PT or die $!;
166     close DS or die $!;
167 }
168
169 sub checksig_keyring ($) {
170     my ($keyringfile) = @_;
171     # returns primary-keyid if signed by a key in this keyring
172     # or undef if not
173     # or dies on other errors
174
175     my $ok = undef;
176
177     open P, "-|", (qw(gpgv --status-fd=1),
178                    map { '--keyring', $_ }, @keyrings,
179                    qw(dgit-tmp/plaintext.asc dgit-tmp/plaintext))
180         or die $!;
181
182     while (<P>) {
183         next unless s/^\[GNUPG:\]: //;
184         chomp or die;
185         my @l = split / /, $_;
186         if ($l[0] eq 'NO_PUBKEY') {
187             last;
188         } elsif ($l[0] eq 'VALIDSIG') {
189             my $sigtype = $l[9];
190             $sigtype eq '00' or reject "signature is not of type 00!";
191             $ok = $l[10];
192             die unless defined $ok;
193             last;
194         }
195     }
196     close P;
197
198     return $ok;
199 }
200
201 sub dm_txt_check ($$) {
202     my ($keyid, $dmtxtfn) = @_;
203     open DT, '<', $dmtxtfn or die "$dmtxtfn $!";
204     while (<DT>) {
205         m/^fingerprint:\s+$keyid$/oi
206             ..0 or next;
207         m/^\S/
208             or reject "key $keyid missing Allow section in permissions!";
209         # in right stanza...
210         s/^allow:/ /i
211             ..0 or next;
212         s/^\s+//
213             or reject "package $package not allowed for key $keyid";
214         # in allow field...
215         s/\([^()]+\)//;
216         s/\,//;
217         foreach my $p (split /\s+/) {
218             return if $p eq $package; # yay!
219         }
220     }
221     DT->error and die $!;
222     close DT or die $!;
223     reject "key $keyid not in permissions list although in keyring!";
224 }
225
226 sub verifytag () {
227     foreach my $kas (split /:/, $keyrings) {
228         $kas =~ s/^([^,]+),// or die;
229         my $keyid = checksig_keyring $1;
230         if (defined $keyid) {
231             if ($kas =~ m/^a$/) {
232                 return; # yay
233             } elsif ($kas =~ m/^m([^,]+)$/) {
234                 dm_txt_check($keyid, $1);
235                 return;
236             } else {
237                 die;
238             }
239         }   
240     }
241     reject "key not found in keyrings";
242 }
243
244 sub checktag () {
245     tagh1('object') eq $commit or die;
246     tagh1('type') eq 'commit' or die;
247     tagh1('tag') eq $tagname or die;
248
249     my $v = $version;
250     $v =~ y/~:/_%/;
251     $tagname eq "debian/$v" or die;
252
253     # check that our ref is being fast-forwarded
254     if ($oldcommit =~ m/[^0]/) {
255         $?=0; $!=0; my $mb = `git merge-base $commit $oldcommit`;
256         chomp $mb;
257         $mb eq $oldcommit or reject "not fast forward on dgit branch";
258     }
259 }
260
261 sub onwardpush () {
262     $!=0;
263     my $r = system (qw(git send-pack),
264                     $destrepo,
265                     "$commit:refs/dgit/$suite",
266                     "$tagval:refs/tags/$tagname");
267     !$r or die "onward push failed: $r $!";
268 }       
269
270 sub stunthook () {
271     chdir $workrepo or die "chdir $workrepo: $!";
272     mkdir "dgit-tmp" or $!==EEXIST or die $!;
273     readupdates();
274     parsetag();
275     verifytag();
276     checktag();
277     onwardpush();
278 }
279
280 #----- arg parsing and main program -----
281
282 sub parseargs () {
283     die unless @ARGV;
284
285     if ($ARGV[0] eq '--pre-receive-hook') {
286         shift @ARGV;
287         @ARGV == 1 or die;
288         $pkg = shift @ARGV;
289         defined($workrepo = $ENV{'DGIT_RPR_WORK'}) or die;
290         defined($destrepo = $ENV{'DGIT_RPR_DEST'}) or die;
291         defined($keyrings = $ENV{'DGIT_RPR_KEYRINGS'}) or die $!;
292         open STDOUT, ">&STDERR" or die $!;
293         stunthook();
294         exit 0;
295     }
296
297     die unless @ARGV>=2;
298
299     die if $ARGV[0] =~ m/^-/;
300     $ENV{'DGIT_RPR_KEYRINGS'} = shift @ARGV;
301     die if $ARGV[0] =~ m/^-/;
302     $dgitrepos = shift @ARGV;
303
304     die unless @ARGV;
305     if ($ARGV[0] != m/^-/) {
306         @ARGV == 1 or die;
307         $pkg = shift @ARGV;
308     } elsif ($ARGV[0] eq '--ssh') {
309         shift @ARGV;
310         !@ARGV or die;
311         my $cmd = $ENV{'SSH_ORIGINAL_COMMAND'};
312         $cmd =~ m{
313             ^
314             (?:\S*/)?
315             (git-receive-pack|git-upload-pack)
316             \s+
317             (?:\S*/)?
318             ($package_re)\.git
319             $
320         }ox 
321             or die "requested command $cmd not understood";
322         $method = $1;
323         $pkg = $2;
324         my $func = $method;
325         $func =~ y/-/_/;
326         $func = $main::{"main__$func"};
327         &$func;
328     } else {
329         die;
330     }
331
332     $destrepo = "$dgitrepos/$pkg.git";
333 }
334
335 sub main__git_receive_pack () {
336     parseargs();
337 fixme check method;
338     makeworkingclone();
339     setupstunthook();
340     runcmd qw(git receive-pack), $destdir;
341 }