chiark / gitweb /
numbered-alias-sheet: introduce canonpaper, nfc
[evade-mail-usrlocal.git] / evade-mail-admin
1 #!/usr/bin/perl -w
2 our $usage1 = <<'END';
3 usage: ../fyvzl [<options>] <database-file> <action> <arg>...
4 options
5   -m<maxperuser>
6   -d<dom>
7   -q<qualdom>
8   -C     (show comments in output)
9   -h     (display help)
10 options for alphanum generation
11   -l<minrandlength>   (for create/choose alphanum, minimum randlength)
12 options for wordlist generation
13   -Wf<wordlist>       (switches generation method to wordlist)
14   -WF<min-word-list-len>             } (for wordlist generation
15   -Wl<min-num-words>                 }   method only)
16   -WL<min-max-mean-word-len>         }
17   -Wd<permitted-delimiter-chars>     } (first char is default; comma = none)
18 END
19 our $usage2 = <<'END';
20 actions
21   create [<genopts>] [<redirect>] [#<comment>]  (default redirect is username)
22   choose [<genopts>] [<redirect>] [#<comment>]  (interactively allocate)
23   update <addr> [<redirect>] [#<comment>]
24   show <addr>
25   list
26   list-actions
27 empty string for redirect means reject
28 remember to quote comments (to protect # from your shell)
29 END
30 our $usage3 = <<'END';
31 privileged actions
32   list-user <user>
33   insert-exact <addr> <user> <redirect> <comment>
34   donate <addr> <user>
35   enable-user|disable-user <user>
36 default generation method is alphanum
37 END
38 our %usage_genopts = (
39 'alphanum' => <<END,
40   -l<randlength>      (number of letters+digits)
41 END
42 'wordlist' => <<END,
43   -l<num-words>       (number of words in output)
44   -d<delim-char>      (delimiter character, "," means none)
45   -F<max-dict-size>   (pick from up to <dictsize> different words, 0 means all)
46   -m<max-addr-len>    (restrict total length of generated addrs, 0 = unlimited)
47 END
48 );
49 #/
50
51 use strict;
52
53 use DBI;
54 use POSIX;
55
56 our $maxperuser = 10000;
57 our $qualdom;
58 our $dbh;
59 our $dom;
60 our $user;
61 our $priv;
62 our $showcomment;
63 our $genmethod = 'alphanum';
64
65 # for alphanum
66 #   options
67 our $minrandlength = 6;
68 our $maxrandlength = 100;
69 #   genopts
70 our $randlength;
71
72 # for wordlist
73 #   options
74 our $wordlist;
75 our $minwordlistlen = 1000;
76 our $minmaxmeanwordlen = 6.2;
77 our $minnumwords = 3;
78 our $maxnumwords = 10;
79 our $worddelims = '.-_,';
80 #   genopts
81 our $numwords;
82 our $worddelim;
83 our $wordlistlen = 3000;
84 our $maxdomainlen = 40;
85
86 sub nextarg () {
87     die "too few arguments\n" unless @ARGV;
88     my $v = shift @ARGV;
89     die "option in wrong place on command line\n" if $v =~ m/^-/;
90     return $v;
91 }
92
93 sub addr2localpart ($) {
94     my ($addr) = @_;
95     return $addr if $addr !~ m/\@/;
96     die "address not in correct domain (\@$dom)\n" unless $' eq $dom; #';
97     return $`; #`;
98 }
99
100 sub nextarg_addr () {
101     return addr2localpart nextarg;
102 }
103
104 sub nomoreargs () {
105     die "too many arguments\n" if @ARGV;
106 }
107
108 sub isdisabled ($) {
109     my ($u) = @_;
110     our $dis_q;
111     $dis_q ||= $dbh->prepare("SELECT * FROM disabled_users WHERE user=?");
112     $dis_q->execute($u);
113     my $row = $dis_q->fetchrow_arrayref();
114     return !!$row;
115 }
116
117 sub puser ($) {
118     my ($u) = @_;
119     our $last_u;
120     if (!defined $last_u or $last_u ne $u) {
121         print "# user $u ".(isdisabled($u) ? 'disabled' : 'enabled')."\n";
122         $last_u = $u;
123     }
124 }
125
126 sub prow ($) {
127     my ($row) = @_;
128     my $u = $row->{'user'};
129     puser($u);
130     my $pa = $row->{'localpart'};
131     $pa .= '@'.$dom if defined $dom;
132     if (length $row->{'redirect'}) {
133         print "$pa: $row->{'redirect'}" or die $!;
134     } else {
135         print "# reject $pa" or die $!;
136     }
137     if ($showcomment || !$priv) {
138         print " #$row->{'comment'}" or die $!;
139     }
140     print "\n" or die $!;
141 }
142
143 sub goodrand ($) {
144     my ($lim) = @_;
145     for (;;) {
146         my $ch;
147         read(R, $ch, 1) == 1 or die $!;
148         my $o = ord $ch;
149         my $v = $o % $lim;
150         next unless $o-$v+$lim < 256;
151 #       print STDERR "goodrand($lim)=$v\n";
152         return $v;
153     }
154 }
155
156 sub qualify (\$) {
157     my ($ref) = @_;
158     if (defined $$ref && $$ref =~ m/[^\041-\177]/) {
159         die "bad characters in redirection target\n";
160     }
161     if (defined $$ref && length $$ref && $$ref !~ m/\@/) {
162         die "unqualified redirection target\n" unless defined $qualdom;
163         $$ref .= '@'.$qualdom;
164     }
165 }
166
167 sub insertrow ($) {
168     my ($row) = @_;
169     my $stmt =
170         "INSERT INTO addrs (".
171         (join ",", sort keys %$row).
172         ") VALUES (".
173         (join ",", map { "?" } sort keys %$row).
174         ") ";
175     $dbh->do($stmt, {}, map { $row->{$_} } sort keys %$row);
176 }
177
178 sub rhsargs ($) {
179     my ($defrow) = @_;
180     my $row = { };
181     while (@ARGV) {
182         $_ = shift @ARGV;
183         my $f = (s/^\#// ? 'comment' : 'redirect');
184         die "$f supplied twice\n" if exists $row->{$f};
185         $row->{$f} = $_;
186     }
187     foreach my $f (keys %$defrow) {
188         next if defined $row->{$f};
189         $row->{$f} = $defrow->{$f};
190     }
191     qualify $row->{'redirect'};
192     return $row;
193 }
194
195 sub local_part_inuse ($) {
196     my ($s) = @_;
197     our $checkexist_q ||=
198         $dbh->prepare("SELECT localpart FROM addrs WHERE localpart=?");
199     $checkexist_q->execute($s);
200     my $row = $checkexist_q->fetchrow_arrayref();
201     return !!$row;
202 }
203
204 sub gen_local_part_alphanum {
205     my $s = chr(ord('a')+goodrand(26));
206     while (length $s < $randlength) {
207         my $v = goodrand(36);
208         $s .= chr($v < 26
209                   ? ord('a')+($v)
210                   : ord('0')+($v-26));
211     }
212     return $s;
213 }
214
215 sub generate_local_part () {
216     my $s;
217     for (;;) {
218         { no strict qw(refs); $s = &{"gen_local_part_$genmethod"}; }
219 #       print STDERR "$s\n";
220         last if !local_part_inuse($s);
221     }
222     return $s;
223 }
224
225 sub prepare_create () {
226     my $countq = $dbh->prepare("SELECT count(*) FROM addrs WHERE user=?");
227     $countq->execute($user);
228     my ($count) = $countq->fetchrow_array();
229     die unless defined $count;
230     die "too many aliases for this user\n" if $count >= $maxperuser;
231     open R, "/dev/urandom" or die $!;
232     binmode R;
233 }
234
235 sub genopt_alphanum {
236     local ($_) = @_;
237     if (m/^-l(\d+)$/) {
238         $randlength = 0+$1;
239         die "length out of range $minrandlength..$maxrandlength\n"
240             unless ($minrandlength<=$randlength &&
241                     $randlength<=$maxrandlength);
242     } else {
243         die "unknown alphanumeric generation option\n";
244     }
245 }
246
247 sub gendefaults_alphanum {
248     $randlength ||= $minrandlength;
249 }
250
251 sub gen_local_part_wordlist {
252     my @cmd = (qw(random-word), "-f$wordlist","-n$numwords");
253     push @cmd, "-F$wordlistlen" if $wordlistlen < 1e9;
254     for (;;) {
255         open P, "-|", @cmd or die $!;
256         my $s = <P>;
257         $!=0; $?=0; close P or die "$? $!";
258         chomp $s or die;
259         $s =~ s/ /$worddelim/g;
260         my $efflen = length $s;
261         $efflen += 1 + length($dom) if defined $dom;
262         return $s if $efflen <= $maxdomainlen;
263     }
264 }
265
266 sub genopt_wordlist {
267     local ($_) = @_;
268     if (m/^-l(\d+)$/) {
269         $numwords = $1;
270         die "length out of range $minnumwords..$maxnumwords\n"
271             unless ($minnumwords<=$numwords &&
272                     $numwords<=$maxnumwords);
273     } elsif (m/^-d(.)$/) {
274         $worddelim = $1;
275         die "word delimiter must be one of \`$worddelims'\n"
276             unless grep { $worddelim eq $_ } split //, $worddelims;
277     } elsif (m/^-F(\d+)$/) {
278         $wordlistlen = $1 ? 0+$1 : 1e9;
279         die "requested dictionary size too small\n"
280             unless $wordlistlen >= $minwordlistlen;
281     } elsif (m/^-m(\d+)$/) {
282         $maxdomainlen = $1 ? 0+$1 : 1e9;
283     } else {
284         die "unknown wordlist generation option\n";
285     }
286 }
287
288 sub gendefaults_wordlist {
289     $numwords ||= $minnumwords;
290     $worddelim = substr($worddelims,0,1) unless defined $worddelim;
291     $worddelim = '' if $worddelim eq ',';
292     my $expectedmindomlen =
293         (defined $dom ? (1 + length $dom) : 0) # @domain.name
294         + $minmaxmeanwordlen * $numwords # some words
295         + (length $worddelim) * ($numwords-1); # delimiters
296     die "assuming lowest reasonable mean word length $minmaxmeanwordlen".
297         " addrs would be $expectedmindomlen long but".
298         " maximum length is set to $maxdomainlen (use different -m?)\n"
299         if $expectedmindomlen > $maxdomainlen;
300 }
301
302 sub genopts {
303     while (@ARGV && $ARGV[0] =~ m/^-/) {
304         my $arg = shift @ARGV;
305         last if $arg =~ m/^--?$/;
306         { no strict qw(refs); &{"genopt_$genmethod"}($arg); }
307     }
308     { no strict qw(refs); &{"gendefaults_$genmethod"}(); }
309 }
310
311 sub action_create {
312     genopts;
313     my $newrow = rhsargs({'redirect'=>$user, 'comment'=>''});
314     prepare_create();
315     $newrow->{'user'} = $user;
316     $newrow->{'localpart'} = generate_local_part();
317     insertrow($newrow);
318     $dbh->commit();
319     prow($newrow);
320 }
321
322 sub action_choose {
323     genopts;
324     $|=1;
325     my $template = rhsargs({'redirect'=>$user, 'comment'=>''});
326     $template->{'user'} = $user;
327     prepare_create();
328     puser($user);
329     my %s;
330     while (keys %s < 10) {
331         my $s = generate_local_part();
332         next if $s{$s};
333         print $s or die $!;
334         print "\@$dom" or die $! if $dom;
335         print "\n" or die $!;
336         $s{$s} = 1;
337     }
338     print "# ready - enter addrs or local-parts to create,".
339         " then \`.' on a line by itself\n"
340         or die $!;
341
342     while (<STDIN>) {
343         chomp;
344         last if m/^\.$/;
345         my $s;
346         if (eval {
347             $s = addr2localpart $_;
348             $s{$s} or die "not an (as-yet-unused) suggestion\n";
349             delete $s{$s};
350             die "just taken in the meantime (bad luck!)\n"
351                 if local_part_inuse $s;
352             1;
353         }) {
354             my $newrow = { %$template, 'localpart' => $s };
355             insertrow($newrow);
356             $dbh->commit();
357             prow($newrow);
358         } else {
359             $dbh->rollback();
360             print "! error: $@" or die $!;
361         }
362     }
363 }
364
365 sub selectrow ($) {
366     my ($localpart) = @_;
367     our $row_q ||= $dbh->prepare("SELECT * FROM addrs WHERE localpart=?");
368     $row_q->execute($localpart);
369     return $row_q->fetchrow_hashref();
370 }
371
372 sub begin_row ($) {
373     my ($localpart) = @_;
374     my $q = $dbh->prepare("SELECT * FROM addrs WHERE localpart=?");
375     my $row = selectrow $localpart;
376     die "unknown localpart\n" unless defined $row;
377     die "not owned by you\n" unless $priv || $row->{user} eq $user;
378     return $row;
379 }
380
381 sub action_update {
382     my $localpart = nextarg_addr;
383     my $updrow = rhsargs({});
384     nomoreargs;
385     begin_row($localpart);
386     foreach my $f (qw(redirect comment)) {
387         my $v = $updrow->{$f};
388         next unless defined $v;
389         $dbh->do("UPDATE addrs SET $f=? WHERE localpart=?",
390                  {}, $v, $localpart);
391     }
392     my $row = selectrow $localpart;
393     $dbh->commit;
394     prow($row);
395 }
396
397 sub action_show {
398     my $localpart = nextarg_addr;
399     nomoreargs;
400     my $row = begin_row($localpart);
401     prow($row);
402 }
403
404 sub listq ($) {
405     my ($q) = @_;
406     while (my $row = $q->fetchrow_hashref()) {
407         prow($row);
408     }
409 }
410
411 sub action_list {
412     nomoreargs;
413     my $q = $dbh->prepare("SELECT * FROM addrs WHERE user=?".
414                           " ORDER BY localpart");
415     $q->execute($user);
416     listq($q);
417 }
418
419 sub action_list_user {
420     die unless $priv;
421     $user = nextarg;
422     nomoreargs;
423     action_list;
424 }
425
426 sub action_list_all {
427     die unless $priv;
428     nomoreargs;
429     my $q = $dbh->prepare("SELECT * FROM addrs".
430                           " ORDER BY user, localpart");
431     $q->execute();
432     listq($q)
433 }
434
435 sub action_insert_exact {
436     die unless $priv;
437     my $row = { };
438     $row->{'localpart'} = nextarg_addr;
439     $row->{'user'} = $user = nextarg;
440     $row->{'redirect'} = nextarg;
441     $row->{'comment'} = nextarg;
442     nomoreargs;
443     insertrow($row);
444     $dbh->commit;
445 }
446
447 sub action_donate {
448     die unless $priv;
449     my $localpart = nextarg_addr;
450     my $newuser = nextarg;
451     nomoreargs;
452     begin_row($localpart);
453     $dbh->do('UPDATE addrs SET user=? WHERE localpart=?',
454              {}, $newuser, $localpart);
455     $dbh->commit;
456 }
457
458 sub action_enable_user {
459     die unless $priv;
460     $user = nextarg;
461     nomoreargs;
462     $dbh->do('DELETE FROM disabled_users WHERE user=?',{},$user);
463     $dbh->commit;
464 }
465
466 sub action_disable_user {
467     die unless $priv;
468     $user = nextarg;
469     nomoreargs;
470     $dbh->do('INSERT INTO disabled_users VALUES user (?)',{},$user);
471     $dbh->commit;
472 }
473
474 sub action_list_actions {
475     print $usage2 or die $!;
476     print "genopts\n" or die $!;
477     print $usage_genopts{$genmethod} or die $!;
478 }
479
480 while (@ARGV) {
481     last unless $ARGV[0] =~ m/^-/;
482     $_ = shift @ARGV;
483     last if m/^--?$/;
484     for (;;) {
485         last unless m/^-./;
486         if (s/^-l(\d+)$//) {
487             $minrandlength = $1;
488         } elsif (s/^-m(\d+)$//) {
489             $maxperuser = $1;
490         } elsif (s/^-d(\S+)$//) {
491             $dom = $1;
492         } elsif (s/^-q(\S+)$//) {
493             $qualdom = $1;
494         } elsif (s/^-Wf(\S+)$//) {
495             $wordlist = $1;
496             $genmethod = 'wordlist';
497         } elsif (s/^-WF(\d+)$//) {
498             $minwordlistlen = $1;
499         } elsif (s/^-Wl(\d+)$//) {
500             $minnumwords = $1;
501         } elsif (s/^-WL([0-9.]+)$//) {
502             $minmaxmeanwordlen = $1;
503         } elsif (s/^-C/-/) {
504             $showcomment = 1;
505         } elsif (s/^-h/-/) {
506             print $usage1.$usage2.$usage3 or die $!;
507             foreach my $meth (qw(alphanum wordlist)) {
508                 print "genopts for $meth generation method\n" or die $!;
509                 print $usage_genopts{$meth} or die $!;
510             }
511             exit 0;
512         } else {
513             die "unknown option \`$_'\n";
514         }
515     }
516 }
517
518 my $dbfile = nextarg();
519
520 if (defined $ENV{'USERV_USER'}) {
521     $priv=0;
522     $user = $ENV{'USERV_USER'};
523 } else {
524     $priv=1;
525     $user = ((getpwuid $<)[0]) or die;
526 }
527
528 $usage2 .= defined $dom
529     ? "addr may be a local part, implicitly qualified with \@$dom\n"
530     : "addr must be a local part (only)\n";
531 $usage2 .= "redirect is implicitly qualified with \@$qualdom".
532     " if it has no \@\n"
533     if defined $qualdom;
534
535 $dbh = DBI->connect("dbi:SQLite:dbname=$dbfile","","",
536                     { PrintError => 0, AutoCommit => 0, RaiseError => 1 }) 
537     or die "$dbfile $!";
538
539 my $action = nextarg();
540 $action =~ y/-/_/;
541 { no strict qw(refs); &{"action_$action"}(); }