chiark / gitweb /
wordlist generator
[d.git] / service
diff --git a/service b/service
index c5dbfe498874e928155fcd3ab1fe3e2ad9419374..5b997bd1bf15fcf280ffd39fbe31d2a132824fe3 100755 (executable)
--- a/service
+++ b/service
@@ -1,37 +1,57 @@
 #!/usr/bin/perl -w
 our $usage1 = <<'END';
-usage: ../fyvzl [OPTIONS DATABASE-FILE ACTION ARG
+usage: ../fyvzl [<options>] <database-file> <action> <arg>...
 options
-  -lLENGTH   (for create)
-  -mMAXPERUSER
-  -dDOM
-  -qQUALDOM
+  -m<maxperuser>
+  -d<dom>
+  -q<qualdom>
   -C     (show comments in output)
   -h     (display help)
+options for alphanum generation
+  -l<minrandlength>   (for create/choose alphanum, minimum randlength)
+options for wordlist generation
+  -Wf<wordlist>       (switches generation method to wordlist)
+  -WF<min-word-list-len>             } (for wordlist generation
+  -Wl<min-num-words>                 }   method only)
+  -WL<min-max-mean-word-len>         }
+  -Wd<permitted-delimiter-chars>     } (first char is default; comma = none)
 END
 our $usage2 = <<'END';
 actions
-  create [REDIRECT] [#COMMENT]    (default for REDIRECT is your username)
-  update LOCAL-PART [REDIRECT] [#COMMENT]
-  show LOCAL-PART
+  create [<genopts>] [<redirect>] [#<comment>]  (default redirect is username)
+  choose [<genopts>] [<redirect>] [#<comment>]  (interactively allocate)
+  update <addr> [<redirect>] [#<comment>]
+  show <addr>
   list
   list-actions
-empty string for REDIRECT means reject
+empty string for redirect means reject
 END
 our $usage3 = <<'END';
 privileged actions
-  list-user USER
-  insert-exact LOCAL-PART USER REDIRECT COMMENT
-  donate LOCAL-PART USER
-  enable-user|disable-user USER
+  list-user <user>
+  insert-exact <addr> <user> <redirect> <comment>
+  donate <addr> <user>
+  enable-user|disable-user <user>
+default generation method is alphanum
 END
+our %usage_genopts = (
+'alphanum' => <<END,
+  -l<randlength>      (number of letters+digits)
+END
+'wordlist' => <<END,
+  -l<num-words>       (number of words in output)
+  -d<delim-char>      (delimiter character, "," means none)
+  -F<max-dict-size>   (pick from up to <dictsize> different words, 0 means all)
+  -m<max-addr-len>    (restrict total length of generated addrs, 0 = unlimited)
+END
+);
+#/
 
 use strict;
 
 use DBI;
 use POSIX;
 
-our $randlength = 6;
 our $maxperuser = 10000;
 our $qualdom;
 our $dbh;
@@ -39,6 +59,28 @@ our $dom;
 our $user;
 our $priv;
 our $showcomment;
+our $genmethod = 'alphanum';
+
+# for alphanum
+#   options
+our $minrandlength = 6;
+our $maxrandlength = 100;
+#   genopts
+our $randlength;
+
+# for wordlist
+#   options
+our $wordlist;
+our $minwordlistlen = 1000;
+our $minmaxmeanwordlen = 6.2;
+our $minnumwords = 3;
+our $maxnumwords = 10;
+our $worddelims = '.-_,';
+#   genopts
+our $numwords;
+our $worddelim;
+our $wordlistlen = 3000;
+our $maxdomainlen = 40;
 
 sub nextarg () {
     die "too few arguments\n" unless @ARGV;
@@ -47,6 +89,17 @@ sub nextarg () {
     return $v;
 }
 
+sub addr2localpart ($) {
+    my ($addr) = @_;
+    return $addr if $addr !~ m/\@/;
+    die "address not in correct domain (\@$dom)\n" unless $' eq $dom; #';
+    return $`; #`;
+}
+
+sub nextarg_addr () {
+    return addr2localpart nextarg;
+}
+
 sub nomoreargs () {
     die "too many arguments\n" if @ARGV;
 }
@@ -60,14 +113,19 @@ sub isdisabled ($) {
     return !!$row;
 }
 
-sub prow ($) {
-    my ($row) = @_;
-    my $u = $row->{'user'};
+sub puser ($) {
+    my ($u) = @_;
     our $last_u;
     if (!defined $last_u or $last_u ne $u) {
        print "# user $u ".(isdisabled($u) ? 'disabled' : 'enabled')."\n";
        $last_u = $u;
     }
+}
+
+sub prow ($) {
+    my ($row) = @_;
+    my $u = $row->{'user'};
+    puser($u);
     my $pa = $row->{'localpart'};
     $pa .= '@'.$dom if defined $dom;
     if (length $row->{'redirect'}) {
@@ -133,38 +191,174 @@ sub rhsargs ($) {
     return $row;
 }
 
-sub action_create {
-    my $newrow = rhsargs({'redirect'=>$user, 'comment'=>''});
-    open R, "/dev/urandom" or die $!;
-    binmode R;
+sub local_part_inuse ($) {
+    my ($s) = @_;
+    our $checkexist_q ||=
+       $dbh->prepare("SELECT localpart FROM addrs WHERE localpart=?");
+    $checkexist_q->execute($s);
+    my $row = $checkexist_q->fetchrow_arrayref();
+    return !!$row;
+}
+
+sub gen_local_part_alphanum {
+    my $s = chr(ord('a')+goodrand(26));
+    while (length $s < $randlength) {
+       my $v = goodrand(36);
+       $s .= chr($v < 26
+                 ? ord('a')+($v)
+                 : ord('0')+($v-26));
+    }
+    return $s;
+}
+
+sub generate_local_part () {
+    my $s;
+    for (;;) {
+       { no strict qw(refs); $s = &{"gen_local_part_$genmethod"}; }
+#      print STDERR "$s\n";
+       last if !local_part_inuse($s);
+    }
+    return $s;
+}
+
+sub prepare_create () {
     my $countq = $dbh->prepare("SELECT count(*) FROM addrs WHERE user=?");
     $countq->execute($user);
     my ($count) = $countq->fetchrow_array();
     die unless defined $count;
     die "too many aliases for this user\n" if $count >= $maxperuser;
-    my $q = $dbh->prepare("SELECT localpart FROM addrs WHERE localpart=?");
-    my $s;
+    open R, "/dev/urandom" or die $!;
+    binmode R;
+}
+
+sub genopt_alphanum {
+    local ($_) = @_;
+    if (m/^-l(\d+)$/) {
+       $randlength = 0+$1;
+       die "length out of range $minrandlength..$maxrandlength\n"
+           unless ($minrandlength<=$randlength &&
+                   $randlength<=$maxrandlength);
+    } else {
+       die "unknown alphanumeric generation option\n";
+    }
+}
+
+sub gendefaults_alphanum {
+    $randlength ||= $minrandlength;
+}
+
+sub gen_local_part_wordlist {
+    my @cmd = (qw(random-word), "-f$wordlist","-n$numwords");
+    push @cmd, "-F$wordlistlen" if $wordlistlen < 1e9;
     for (;;) {
-       $s = chr(ord('a')+goodrand(26));
-       while (length $s < $randlength) {
-           my $v = goodrand(36);
-           $s .= chr($v < 26
-                     ? ord('a')+($v)
-                     : ord('0')+($v-26));
-       }
-#      print STDERR "$s\n";
-       $q->execute($s);
-       my $row = $q->fetchrow_arrayref();
-       last if !$row;
-       $dbh->abort();
+       open P, "-|", @cmd or die $!;
+       my $s = <P>;
+       $!=0; $?=0; close P or die "$? $!";
+       chomp $s or die;
+       $s =~ s/ /$worddelim/g;
+       my $efflen = length $s;
+       $efflen += 1 + length($dom) if defined $dom;
+       return $s if $efflen <= $maxdomainlen;
+    }
+}
+
+sub genopt_wordlist {
+    local ($_) = @_;
+    if (m/^-l(\d+)$/) {
+       $numwords = $1;
+       die "length out of range $minnumwords..$maxnumwords\n"
+           unless ($minnumwords<=$numwords &&
+                   $numwords<=$maxnumwords);
+    } elsif (m/^-d(.)$/) {
+       $worddelim = $1;
+       die "word delimiter must be one of \`$worddelims'\n"
+           unless grep { $worddelim eq $_ } split //, $worddelims;
+    } elsif (m/^-F(\d+)$/) {
+       $wordlistlen = $1 ? 0+$1 : 1e9;
+       die "requested dictionary size too small\n"
+           unless $wordlistlen >= $minwordlistlen;
+    } elsif (m/^-m(\d+)$/) {
+       $maxdomainlen = $1 ? 0+$1 : 1e9;
+    } else {
+       die "unknown wordlist generation option\n";
     }
+}
+
+sub gendefaults_wordlist {
+    $numwords ||= $minnumwords;
+    $worddelim = substr($worddelims,0,1) unless defined $worddelim;
+    $worddelim = '' if $worddelim eq ',';
+    my $expectedmindomlen =
+       (defined $dom ? (1 + length $dom) : 0) # @domain.name
+       + $minmaxmeanwordlen * $numwords # some words
+       + (length $worddelim) * ($numwords-1); # delimiters
+    die "assuming lowest reasonable mean word length $minmaxmeanwordlen".
+       " addrs would be $expectedmindomlen long but".
+       " your maximum length specified $maxdomainlen\n"
+       if $expectedmindomlen > $maxdomainlen;
+}
+
+sub genopts {
+    while (@ARGV && $ARGV[0] =~ m/^-/) {
+       my $arg = shift @ARGV;
+       last if $arg =~ m/^--?$/;
+       { no strict qw(refs); &{"genopt_$genmethod"}($arg); }
+    }
+    { no strict qw(refs); &{"gendefaults_$genmethod"}(); }
+}
+
+sub action_create {
+    genopts;
+    my $newrow = rhsargs({'redirect'=>$user, 'comment'=>''});
+    prepare_create();
     $newrow->{'user'} = $user;
-    $newrow->{'localpart'} = $s;
+    $newrow->{'localpart'} = generate_local_part();
     insertrow($newrow);
     $dbh->commit();
     prow($newrow);
 }
 
+sub action_choose {
+    genopts;
+    my $template = rhsargs({'redirect'=>$user, 'comment'=>''});
+    $template->{'user'} = $user;
+    prepare_create();
+    puser($user);
+    my %s;
+    while (keys %s < 10) {
+       my $s = generate_local_part();
+       next if $s{$s};
+       print $s or die $!;
+       print "\@$dom" or die $! if $dom;
+       print "\n" or die $!;
+       $s{$s} = 1;
+    }
+    print "# ready - enter addrs or local-parts to create,".
+       " then \`.' on a line by itself\n"
+       or die $!;
+
+    while (<STDIN>) {
+       chomp;
+       last if m/^\.$/;
+       my $s;
+       if (eval {
+           $s = addr2localpart $_;
+           $s{$s} or die "not an (as-yet-unused) suggestion\n";
+           delete $s{$s};
+           die "just taken in the meantime (bad luck!)\n"
+               if local_part_inuse $s;
+           1;
+       }) {
+           my $newrow = { %$template, 'localpart' => $s };
+           $dbh->commit();
+           prow($newrow);
+       } else {
+           $dbh->rollback();
+           print "! error: $@" or die $!;
+       }
+    }
+}
+
 sub selectrow ($) {
     my ($localpart) = @_;
     our $row_q ||= $dbh->prepare("SELECT * FROM addrs WHERE localpart=?");
@@ -182,7 +376,7 @@ sub begin_row ($) {
 }
 
 sub action_update {
-    my $localpart = nextarg;
+    my $localpart = nextarg_addr;
     my $updrow = rhsargs({});
     nomoreargs;
     begin_row($localpart);
@@ -198,7 +392,7 @@ sub action_update {
 }
 
 sub action_show {
-    my $localpart = nextarg;
+    my $localpart = nextarg_addr;
     nomoreargs;
     my $row = begin_row($localpart);
     prow($row);
@@ -238,7 +432,7 @@ sub action_list_all {
 sub action_insert_exact {
     die unless $priv;
     my $row = { };
-    $row->{'localpart'} = nextarg;
+    $row->{'localpart'} = nextarg_addr;
     $row->{'user'} = $user = nextarg;
     $row->{'redirect'} = nextarg;
     $row->{'comment'} = nextarg;
@@ -249,7 +443,7 @@ sub action_insert_exact {
 
 sub action_donate {
     die unless $priv;
-    my $localpart = nextarg;
+    my $localpart = nextarg_addr;
     my $newuser = nextarg;
     nomoreargs;
     begin_row($localpart);
@@ -276,6 +470,8 @@ sub action_disable_user {
 
 sub action_list_actions {
     print $usage2 or die $!;
+    print "genopts\n" or die $!;
+    print $usage_genopts{$genmethod} or die $!;
 }
 
 while (@ARGV) {
@@ -285,17 +481,30 @@ while (@ARGV) {
     for (;;) {
        last unless m/^-./;
        if (s/^-l(\d+)$//) {
-           $randlength = $1;
+           $minrandlength = $1;
        } elsif (s/^-m(\d+)$//) {
            $maxperuser = $1;
        } elsif (s/^-d(\S+)$//) {
            $dom = $1;
        } elsif (s/^-q(\S+)$//) {
            $qualdom = $1;
+       } elsif (s/^-Wf(\S+)$//) {
+           $wordlist = $1;
+           $genmethod = 'wordlist';
+       } elsif (s/^-WF(\d+)$//) {
+           $minwordlistlen = $1;
+       } elsif (s/^-Wl(\d+)$//) {
+           $minnumwords = $1;
+       } elsif (s/^-WL([0-9.]+)$//) {
+           $minmaxmeanwordlen = $1;
        } elsif (s/^-C/-/) {
            $showcomment = 1;
        } elsif (s/^-h/-/) {
            print $usage1.$usage2.$usage3 or die $!;
+           foreach my $meth (qw(alphanum wordlist)) {
+               print "genopts for $meth generation method\n" or die $!;
+               print $usage_genopts{$meth} or die $!;
+           }
            exit 0;
        } else {
            die "unknown option \`$_'\n";
@@ -313,9 +522,11 @@ if (defined $ENV{'USERV_USER'}) {
     $user = ((getpwuid $<)[0]) or die;
 }
 
-$usage2 .= "LOCAL-PART is implicitly qualified with \@$dom\n"
-    if defined $qualdom;
-$usage2 .= "REDIRECT is implicitly qualified with \@$qualdom if it has no \@\n"
+$usage2 .= defined $dom
+    ? "addr may be a local part, implicitly qualified with \@$dom\n"
+    : "addr must be a local part (only)\n";
+$usage2 .= "redirect is implicitly qualified with \@$qualdom".
+    " if it has no \@\n"
     if defined $qualdom;
 
 $dbh = DBI->connect("dbi:SQLite:dbname=$dbfile","","",