-
Notifications
You must be signed in to change notification settings - Fork 2
/
totport
executable file
·290 lines (215 loc) · 8.14 KB
/
totport
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
#!/usr/bin/perl
use strict;
use warnings;
# TODO: IPv6
# ----------------------------------------------------------------------
# LOCAL CONFIGURATION
# mandatory:
my $RC = "$ENV{HOME}/totport.rc";
# the rest have reasonable defaults here:
use lib "$ENV{HOME}/bin"; # location of Utils.pm
our $KEYDIR = "$ENV{HOME}/keydir";
our $AKD = "$ENV{HOME}/.ssh";
our $AKF = "$ENV{HOME}/.ssh/authorized_keys";
our $TOTP = "$ENV{HOME}/bin/totp";
our $THIS = "$ENV{HOME}/bin/totport";
our $LOGF = "$ENV{HOME}/totport.log";
our $VAL_KEYS = "$ENV{HOME}/validated_keys";
# but can be overridden by placing lines similar to those in the rc file.
# That is, they are all optional in the rc file.
# PLEASE MAKE SURE ALL PATHS ARE ABSOLUTE; they get used when the process is
# often in some other directory!
our %port_expander;
# The rc file, and one specific entry within it, are mandatory. Please see
# t/totport.rc.sample for more info.
# now pull in the rc file
do $RC;
# this is non-negotiable :)
my $AKOPTIONS = "no-X11-forwarding,no-agent-forwarding,no-pty";
# ----------------------------------------------------------------------
use 5.10.0;
use Data::Dumper;
$Data::Dumper::Terse = 1;
$Data::Dumper::Indent = 1;
$Data::Dumper::Sortkeys = 1;
use File::Basename;
use Utils;
umask 0077;
_logstart($LOGF);
_die "rc file missing or doesn't have what I want" unless $port_expander{NONE};
# ----------------------------------------------------------------------
# main
my $CT = time();
# first, log SSH env stuff
_log( "SSH_CONNECTION", $ENV{SSH_CONNECTION} ) if $ENV{SSH_CONNECTION};
_log( "SSH_ORIGINAL_COMMAND", $ENV{SSH_ORIGINAL_COMMAND} ) if $ENV{SSH_ORIGINAL_COMMAND};
my $conn_ip = conn_ip();
# dispatch
my $cmd = shift or _die "need command or user name";
if ( $cmd eq 'rebuild' ) {
rebuild_afk();
exit 0;
}
# any other local commands go here
# ...
# from here, it's a remote invocation by a user, which means what we thought
# was $cmd till now is actually a user name :)
my $user = $cmd;
my $ip = shift || 0;
if ( $ip and not $ENV{SSH_ORIGINAL_COMMAND} ) {
# this is an IP-validated run. Port forwards are already in place.
# We're not expected to do anything, and indeed if the user runs 'ssh
# -N' we won't even run!
say STDERR "hi $user, you may use your forwarded ports now. Use Ctrl-C when done";
_log "valid access user=$user validated-ip=$ip from=$conn_ip";
# sanity check to make sure user is present
my $ports = `$TOTP -d $user ports`;
_die 'totp database out of sync with authkeys' if ${^CHILD_ERROR_NATIVE};
sleep 2;
exit 0 if $ENV{TOTPORT_TEST};
sleep 10*60*60;
say STDERR "goodbye";
exit 0;
}
# user is enrolling
if ( ( $ENV{SSH_ORIGINAL_COMMAND} || '' ) eq 'enroll' ) {
_log();
system( $TOTP, "-a", $user ) and exit 1;
exit 0;
}
# user wants to get a qrcode
if ( ( $ENV{SSH_ORIGINAL_COMMAND} || '' ) eq 'qrcode' ) {
_log();
say STDERR "ok; you have 20 minutes to generate and scan your QR codes";
sleep 1200; # should be long enough for ANYONE!
exit 0;
}
# user supplied a otp ("ssh totport@host val 12345678")
if ( ( $ENV{SSH_ORIGINAL_COMMAND} || '' ) =~ /^val (\d+)$/ ) {
_log();
my $otp = $1;
if ( system( $TOTP, "-c", $user, $otp ) ) {
# note that "true" from system() means things *failed*!
_log("invalidating '$user'");
rebuild_afk();
_die "totp not ok";
} else {
# ...and false is "OK"
_log("validating '$conn_ip' for '$user' with totp '$otp'");
my $valm = validate( $user, $conn_ip );
say STDERR "validated '$user' from '$conn_ip'; IP valid for $valm minutes";
rebuild_afk();
exit 0;
}
}
_log(); # triggers logging of argv without logging anything else
rebuild_afk();
say STDERR "hello $user, you're authenticated but your IP is not validated";
say STDERR "commands:\n\tenroll\n\tqrcode\n\tval <TOTP>";
# ----------------------------------------------------------------------
sub validate {
my $user = shift;
my $conn_ip = shift;
mkdir $VAL_KEYS;
_chdir $VAL_KEYS;
for my $ev ( glob("*/$user") ) {
_warn "removing existing validation '$ev'";
unlink $ev;
rmdir(dirname($ev));
}
# "valid for 20 minutes" translates to "valid for time() + 1200 seconds"
my $valm = valid_for( $user, $conn_ip );
my $use_by = time() + $valm * 60;
# strings like "MAIL", "GIT1", or "MAIL,GIT1" (which contains both), etc...
my $ports = `$TOTP -d $user ports` || '';
$ports .= ",DEFAULT" if $port_expander{DEFAULT} and not $ports =~ /\bDEFAULT\b/;
_die "no valid ports defined for '$user', why bother validating?" unless $ports;
_die "invalid format '$ports'" unless $ports =~ /^(\w+)(,\w+)*$/;
# expand port symbolic names into actual names
$ports =~ s/(\w+)/$port_expander{$1}/g;
# generate the option string
my $option = "command=\"$THIS $user $conn_ip\",from=\"$conn_ip\",no-X11-forwarding,no-agent-forwarding,no-pty,$ports";
my $pub = slurp("$KEYDIR/$user.pub");
mkdir $use_by; # yes Virginia, a directory name like '1410764927'
_print( "$use_by/$user", "$option $pub" );
return $valm;
}
sub rebuild_afk {
my $CT = time();
my $akt = ''; # text of new authorized_keys file
mkdir $VAL_KEYS;
_chdir $VAL_KEYS;
for my $vkd ( sort glob("*") ) {
next unless $vkd =~ /^\d{10}$/; # safe for another 272 years :)
unless ( -d "$VAL_KEYS/$vkd" ) {
_warn "'$VAL_KEYS/$vkd' exists but is not a directory!";
next;
}
if ( $CT > $vkd ) {
map { chomp; _log($_); } `rm -vrf $VAL_KEYS/$vkd`;
next;
}
# now rebuild the ak file
chdir "$VAL_KEYS/$vkd";
for my $vk ( sort glob("*") ) {
$akt .= slurp($vk);
}
}
mkdir $KEYDIR;
_chdir $KEYDIR;
my $option = "command=\"$THIS %USER\",no-X11-forwarding,no-agent-forwarding,no-pty,$port_expander{NONE}";
for my $pkf ( sort glob("*.pub") ) {
my $pub = slurp($pkf);
$pub .= "\n" unless $pub =~ /\n$/;
my $user = $pkf;
$user =~ s/\.pub$//; # basename is username; no fancy gitolite tricks here!
$pub = "$option $pub";
$pub =~ s/%USER/$user/;
$akt .= $pub;
}
mkdir $AKD unless -d $AKD;
_print( $AKF, $akt );
}
# ----------------------------------------------------------------------
# service routines
sub conn_ip {
my $ip;
( $ip = $ENV{SSH_CONNECTION} || '' ) =~ s/ .*//;
return $ip;
}
# compute how long this IP should remain valid. The idea is to base this on
# how often have we seen this IP before -- the more often we have, the more
# time it is given (subject to some cap).
# EXPERIMENTAL CODE. Contains some constants -- 5, 150... -- to be made more
# properly customisable once we work the kinks out.
sub valid_for {
my ( $user, $conn_ip ) = @_;
# base validity in minutes
my $base = ( `$TOTP -d $user valid_for` || 20 );
my $recent_IPs = ( `$TOTP -d $user recent_IPs` || '' );
my %hits;
my $lfu = ''; # least frequently used IP
my $count = 0;
while ( $recent_IPs =~ /\b([\d.]+)=(\d+)\b/g ) {
my ( $ip, $hits ) = ( $1, $2 );
$hits{$ip} = $hits;
$count++;
# current IP is never a candidate for LFU
next if $ip eq $conn_ip;
$lfu ||= $ip;
$lfu = $ip if $hits{$lfu} and $hits{$lfu} > $hits;
}
# bump count if current IP was not seen before
$count++ unless $hits{$conn_ip};
# purge LFU if there would then be more than 5
delete $hits{$lfu} if $lfu and $count > 5;
# bump hits for connected IP but set a cap of 150 on the hits; if $base is
# the default (20 minutes), this is 50 hours; just a bit over 2 days
$hits{$conn_ip}++;
$hits{$conn_ip} = 150 if $hits{$conn_ip} > 150;
# write the new counts out to the database
$recent_IPs = join " ", map { "$_=$hits{$_}" } sort keys %hits;
system( $TOTP, '-u', $user, 'recent_IPs', '=', $recent_IPs ) and _die "update recent_IPs failed";
# remember each hit is worth $base minutes of validity!
return $base * $hits{$conn_ip};
}