19 # Heiko Schlittermann <hs@schlittermann.de> |
19 # Heiko Schlittermann <hs@schlittermann.de> |
20 |
20 |
21 use strict; |
21 use strict; |
22 use warnings; |
22 use warnings; |
23 use Getopt::Long; |
23 use Getopt::Long; |
24 use IO::Socket::INET; |
|
25 use Pod::Usage; |
24 use Pod::Usage; |
26 use File::Basename; |
25 use File::Basename; |
|
26 use if $ENV{DEBUG} => "Smart::Comments"; |
27 use POSIX qw(:sys_wait_h setsid); |
27 use POSIX qw(:sys_wait_h setsid); |
28 use Cwd; |
28 use Cwd qw(abs_path getcwd); |
29 |
29 use Authen::Simple::Passwd; |
30 my $opt_addr = "0.0.0.0"; |
30 use MIME::Base64 qw(decode_base64); |
31 my $opt_port = 8080; |
31 use IO::Socket::INET; |
32 my $opt_lines = 10; |
32 use IO::Socket::SSL; |
33 my $opt_daemon = 1; |
|
34 my $opt_kill = 0; |
|
35 |
33 |
36 my $ME = basename $0; |
34 my $ME = basename $0; |
37 |
35 |
|
36 my $opt_addr = "0.0.0.0"; |
|
37 my $opt_auth = $ME; |
|
38 my $opt_port = 8080; |
|
39 my $opt_lines = 10; |
|
40 my $opt_daemon = 1; |
|
41 my $opt_kill = 0; |
|
42 my $opt_debug = 0; |
|
43 my $opt_htpasswd = "htpasswd"; |
|
44 my $opt_ssl = 1; |
|
45 my $opt_ssl_cert = "crt.pem"; |
|
46 my $opt_ssl_key = "key.pem"; |
|
47 |
38 # these vars will be filled with the real dirs later |
48 # these vars will be filled with the real dirs later |
39 my $rundir = [ "/var/run/$ME", "$ENV{HOME}/.$ME" ]; |
49 my $rundir = ["/var/run/$ME", "$ENV{HOME}/.$ME"]; |
40 my $logdir = [ "/var/log/$ME", "$ENV{HOME}/.$ME" ]; |
50 my $logdir = ["/var/log/$ME", "$ENV{HOME}/.$ME"]; |
41 |
51 |
42 my $maxlogsize = 1000_000_000; # ca 1 MByte |
52 my $maxlogsize = 1_000_000; # ca 1 MByte |
43 my $killtimeout = 3; |
53 my $killtimeout = 3; |
44 |
54 |
45 # these are refs to detect if they're converted already |
55 # these are refs to detect if they're converted already |
46 my $access = \"%s/access.log"; |
56 my $access = \"%s/access.log"; |
47 my $errors = \"%s/error.log"; |
57 my $errors = \"%s/error.log"; |
48 my $pidfile = \"%s/%s.%s.pid"; # %dir/%ip.%port |
58 my $pidfile = \"%s/%s.%s.pid"; # %dir/%ip.%port |
|
59 |
|
60 # remember the pid that is actually written to the pid file so we can ensure |
|
61 # that only the process with that pid is attempting to remove the pidfile at |
|
62 # exit |
|
63 my $masterpid; |
49 |
64 |
50 END { |
65 END { |
51 unlink $pidfile |
66 unlink $pidfile |
52 if defined $pidfile and not ref $pidfile; |
67 if defined $pidfile |
|
68 and not ref $pidfile |
|
69 and defined $masterpid |
|
70 and $masterpid == $$; |
53 } |
71 } |
54 |
72 |
55 sub find_writable_dir(@); |
73 sub find_writable_dir(@); |
56 |
74 |
57 sub log_open($); |
75 sub log_open($); |
61 sub http($@); |
79 sub http($@); |
62 |
80 |
63 sub bad_request(); |
81 sub bad_request(); |
64 sub date1123(;$); |
82 sub date1123(;$); |
65 |
83 |
|
84 sub authenticated($$); |
|
85 |
66 my %FILE; |
86 my %FILE; |
67 |
87 |
68 MAIN: { |
88 MAIN: { |
69 |
89 |
70 GetOptions( |
90 GetOptions( |
71 "addr=s" => \$opt_addr, |
91 "addr=s" => \$opt_addr, |
|
92 "auth:s" => sub { $opt_auth = $_[1] eq '' ? $ME : $_[1] }, |
|
93 "noauth" => sub { undef $opt_auth }, |
72 "port=i" => \$opt_port, |
94 "port=i" => \$opt_port, |
73 "lines=i" => \$opt_lines, |
95 "lines=i" => \$opt_lines, |
74 "daemon!" => \$opt_daemon, |
96 "daemon!" => \$opt_daemon, |
|
97 "debug!" => \$opt_debug, |
75 "kill" => \$opt_kill, |
98 "kill" => \$opt_kill, |
76 "help" => sub { pod2usage(-verbose => 1, -exitval => 0) }, |
99 "help" => sub { pod2usage(-verbose => 1, -exitval => 0) }, |
77 "man" => sub { pod2usage(-verbose => 2, -exitval => 0) }, |
100 "man" => sub { pod2usage(-verbose => 2, -exitval => 0) }, |
|
101 "htpasswd=s" => \$opt_htpasswd, |
|
102 "ssl!" => \$opt_ssl, |
|
103 "ssl-cert=s" => \$opt_ssl_cert, |
|
104 "ssl-key=s" => \$opt_ssl_key |
78 ) or pod2usage(); |
105 ) or pod2usage(); |
|
106 |
|
107 if ($opt_kill) { |
|
108 $opt_auth = 0; |
|
109 $opt_ssl = 0; |
|
110 } |
|
111 |
|
112 foreach ($opt_htpasswd, $opt_ssl_key, $opt_ssl_cert) { |
|
113 $_ = abs_path($_) if defined; |
|
114 } |
|
115 |
|
116 ### $opt_ssl_key |
|
117 ### $opt_ssl_cert |
|
118 ### $opt_auth |
79 |
119 |
80 if (defined($logdir = find_writable_dir(@$logdir))) { |
120 if (defined($logdir = find_writable_dir(@$logdir))) { |
81 $access = sprintf $$access, $logdir; |
121 $access = sprintf $$access, $logdir; |
82 $errors = sprintf $$errors, $logdir; |
122 $errors = sprintf $$errors, $logdir; |
83 log_open($access); |
123 log_open($access); |
117 pod2usage() if not @ARGV; |
157 pod2usage() if not @ARGV; |
118 |
158 |
119 # resolve tags and filenames |
159 # resolve tags and filenames |
120 foreach (@ARGV) { |
160 foreach (@ARGV) { |
121 $_ = "default=$_" if not /=/ or /^\//; |
161 $_ = "default=$_" if not /=/ or /^\//; |
|
162 |
122 my ($tag, $file) = split /=/, $_, 2; |
163 my ($tag, $file) = split /=/, $_, 2; |
|
164 |
123 die "tag $tag already exists with file $FILE{$tag}\n" |
165 die "tag $tag already exists with file $FILE{$tag}\n" |
124 if exists $FILE{$tag}; |
166 if exists $FILE{$tag}; |
125 $file = getcwd() . "/$file" if $file !~ /^\//; |
167 $file = abs_path($file); |
126 $FILE{$tag} = $file; |
168 $FILE{$tag} = $file; |
127 } |
169 } |
128 |
170 |
129 # start the listener |
171 # Start the listener, just a normal INET socket, |
|
172 # SSL will be started later on, if needed.. |
130 my $listener = new IO::Socket::INET( |
173 my $listener = new IO::Socket::INET( |
131 LocalAddr => $opt_addr, |
174 LocalAddr => $opt_addr, |
132 LocalPort => $opt_port, |
175 LocalPort => $opt_port, |
133 Proto => "tcp", |
176 Proto => "tcp", |
134 Listen => 1, |
177 Listen => 1, |
135 ReuseAddr => 1, |
178 ReuseAddr => 1, |
136 ) or die "Can't create listener socket: $!\n"; |
179 ) or die "Can't create listener: $!\n"; |
137 |
180 |
138 # go daemon |
181 # go daemon |
139 chdir("/") or die "Can't chdir to /: $!\n"; |
182 chdir("/") or die "Can't chdir to /: $!\n"; |
140 |
183 |
141 if ($opt_daemon) { |
184 if ($opt_daemon) { |
188 |
231 |
189 my $pid = fork(); |
232 my $pid = fork(); |
190 die "Can't fork: $!\n" if not defined $pid; |
233 die "Can't fork: $!\n" if not defined $pid; |
191 if ($pid == 0) { |
234 if ($pid == 0) { |
192 $SIG{CHLD} = "DEFAULT"; |
235 $SIG{CHLD} = "DEFAULT"; |
193 $listener->close; |
236 $listener->close(); |
|
237 if ($opt_ssl) { |
|
238 $client = IO::Socket::SSL->new_from_fd( |
|
239 $client, |
|
240 SSL_server => 1, |
|
241 SSL_key_file => $opt_ssl_key, |
|
242 SSL_cert_file => $opt_ssl_cert, |
|
243 ); |
|
244 $client->start_SSL; |
|
245 } |
194 handle_request($client); |
246 handle_request($client); |
195 exit 0; |
247 exit 0; |
196 } |
248 } |
197 $client->close; |
249 $client->close(); |
198 |
250 |
199 # maintenance of logfiles |
251 # maintenance of logfiles |
200 if (-s $access > $maxlogsize) { |
252 if (-s $access > $maxlogsize) { |
201 rename $access, "$access.1"; |
253 rename $access, "$access.1"; |
202 log_open($access); |
254 log_open($access); |
232 |
284 |
233 } |
285 } |
234 |
286 |
235 sub handle_request($) { |
287 sub handle_request($) { |
236 my $client = shift; |
288 my $client = shift; |
|
289 |
237 local $_ = <$client>; |
290 local $_ = <$client>; |
238 |
291 |
239 # should be HTTP/x.x |
292 # should be HTTP(S)/x.x |
240 if (not s/\s+HTTP\/\S+\s*$//) { |
293 if (not s/\s+HTTPS?\/\S+\s*$//) { |
|
294 log_write("Bad Request: $_") if $opt_debug; |
241 $client->print(bad_request); |
295 $client->print(bad_request); |
242 return; |
296 return; |
243 } |
297 } |
244 |
298 |
245 # should be a GET request |
299 # should be a GET request |
246 if (not s/GET\s+//) { |
300 if (not s/GET\s+//) { |
|
301 log_write("Bad Request: $_") if $opt_debug; |
247 $client->print(http "400 Bad Request" => bad_request); |
302 $client->print(http "400 Bad Request" => bad_request); |
248 } |
303 } |
249 |
304 |
250 # number of lines and tag to show |
305 # number of lines and tag to show |
251 my $lines = (s/(\d+)$// ? $1 : $opt_lines); |
306 my $lines = (s/(\d+)$// ? $1 : $opt_lines); |
252 my $tag = (s/^\/*(\w+)// ? $1 : "default"); |
307 my $tag = (s/^\/*(\w+)// ? $1 : "default"); |
253 |
308 |
254 # read the header(s) and discard |
309 my $authenticated = defined $opt_auth ? 0 : 1; |
255 while (<$client>) { last if /^\s*$/ } |
310 ### $authenticated |
|
311 |
|
312 # read and verify (first) authentication header and discard any other headers |
|
313 while (<$client>) { |
|
314 last if /^\s*$/; |
|
315 next if $authenticated; |
|
316 |
|
317 if (/^Authorization:\s+Basic\s+([[:alnum:]+\/=]+)\r?$/) { |
|
318 $authenticated = authenticate($opt_htpasswd => $1) |
|
319 or log_write("authentication failure from " . $client->peerhost); |
|
320 } |
|
321 |
|
322 } |
|
323 ### $authenticated |
|
324 |
|
325 unless ($authenticated) { |
|
326 |
|
327 $client->print( |
|
328 http { |
|
329 code => "401 Unauthorized", |
|
330 headers => |
|
331 { "WWW-Authenticate" => "Basic realm=\"$opt_auth\"", } |
|
332 }, |
|
333 "not authorized" |
|
334 ); |
|
335 return; |
|
336 |
|
337 } |
256 |
338 |
257 if (not exists $FILE{$tag}) { |
339 if (not exists $FILE{$tag}) { |
258 $client->print(http "500 unknown file tag", |
340 $client->print(http "500 unknown file tag", |
259 "Sorry, unknown file tag \"$tag\""); |
341 "Sorry, unknown file tag \"$tag\""); |
260 log_write("unknown tag $tag"); |
342 log_write("unknown tag $tag"); |
268 } |
350 } |
269 |
351 |
270 log_write($client->peerhost . ":" . $client->peerport . " $tag ($lines)"); |
352 log_write($client->peerhost . ":" . $client->peerport . " $tag ($lines)"); |
271 |
353 |
272 seek($file{fh}, -($lines + 1) * $file{avglen}, 2); |
354 seek($file{fh}, -($lines + 1) * $file{avglen}, 2); |
273 $file{fh}->getline; |
355 |
|
356 # warum das? $file{fh}->getline; |
274 |
357 |
275 $client->print(http "200 OK" => join "", <<__EOF, $file{fh}->getlines); |
358 $client->print(http "200 OK" => join "", <<__EOF, $file{fh}->getlines); |
276 # Proof of concept ;-) |
359 # Proof of concept ;-) |
277 # see https://keller.schlittermann.de/hg/hlog |
360 # see https://keller.schlittermann.de/hg/hlog |
278 # |
361 # |
314 seek($r{fh}, 0, 0); |
397 seek($r{fh}, 0, 0); |
315 return %r; |
398 return %r; |
316 } |
399 } |
317 |
400 |
318 sub http($@) { |
401 sub http($@) { |
319 my $code = shift; |
402 |
320 my $date = date1123(); |
403 my ($headers, $code, $date) = (''); |
|
404 |
|
405 if (ref $_[0] eq "HASH") { |
|
406 |
|
407 my $h; |
|
408 ($code, $date, $h) = @{ $_[0] }{ 'code', 'date', 'headers' }; |
|
409 $headers = (join "\n", map { "$_: $h->{$_}" } keys %{$h}) . "\n" |
|
410 if defined $h; |
|
411 shift; |
|
412 |
|
413 } |
|
414 else { |
|
415 |
|
416 $code = shift; |
|
417 |
|
418 } |
|
419 |
|
420 $date ||= date1123(); |
321 |
421 |
322 my $type = $_[0] =~ /^<!DOCTYPE HTML/ ? "text/html" : "text/plain"; |
422 my $type = $_[0] =~ /^<!DOCTYPE HTML/ ? "text/html" : "text/plain"; |
323 |
423 |
324 return <<__EOF, @_; |
424 return <<__EOF, @_; |
325 HTTP/1.1 $code |
425 HTTP/1.1 $code |
326 Date: $date |
426 Date: $date |
327 Connection: close |
427 Connection: close |
328 Content-Type: $type |
428 Content-Type: $type |
329 |
429 $headers |
330 __EOF |
430 __EOF |
331 } |
431 } |
332 |
432 |
333 sub date1123(;$) { |
433 sub date1123(;$) { |
334 my @now = gmtime(@_ ? shift : time); |
434 my @now = gmtime(@_ ? shift : time); |
335 sprintf "%s, %2d %s %4d %02d:%02d:%02d GMT", |
435 sprintf "%s, %2d %s %4d %02d:%02d:%02d GMT", |
336 qw(Sun Mon Tue Wed Thu Fri Sat Sun) [ $now[6] ], |
436 qw(Sun Mon Tue Wed Thu Fri Sat Sun) [$now[6]], |
337 $now[3], |
437 $now[3], |
338 qw(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec) [ $now[4] ], |
438 qw(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec) [$now[4]], |
339 $now[5] + 1900, @now[ 2, 1, 0 ]; |
439 $now[5] + 1900, @now[2, 1, 0]; |
340 } |
440 } |
341 |
441 |
342 sub bad_request() { |
442 sub bad_request() { |
343 return <<'__EOF'; |
443 return <<'__EOF'; |
344 <!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN"> |
444 <!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN"> |
351 </p> |
451 </p> |
352 </body></html> |
452 </body></html> |
353 __EOF |
453 __EOF |
354 } |
454 } |
355 |
455 |
|
456 sub authenticate($$) { |
|
457 my ($htpasswd, $userinfo) = @_; |
|
458 my $auth = new Authen::Simple::Passwd(path => $htpasswd) |
|
459 or die "Can't open \"$htpasswd\": $!\n"; |
|
460 $auth->authenticate(split /:/, decode_base64($userinfo)); |
|
461 } |
|
462 |
356 __END__ |
463 __END__ |
357 |
464 |
358 =head1 NAME |
465 =head1 NAME |
359 |
466 |
360 hlog - simple http server providing access to some logfile |
467 hlog - simple http server providing access to some logfile |
361 |
468 |
362 =head1 SYNOPSIS |
469 =head1 SYNOPSIS |
363 |
470 |
364 hlog [--[no]daemon] |
471 hlog [--[no]daemon] |
|
472 [--[no]debug] |
365 [-k|--kill] |
473 [-k|--kill] |
366 [-a|--address address] [-p|--port port] |
474 [-a|--address address] [-p|--port port] |
367 [--lines n] |
475 [--lines n] |
|
476 [--htpasswd path] |
|
477 [--[no]ssl] |
|
478 [--auth=[realm] | --noauth] |
|
479 [--ssl-cert path] |
|
480 [--ssl-key path] |
368 {file|tag=file ...} |
481 {file|tag=file ...} |
369 |
482 |
370 hlog [-h|--help] [-m|--man] |
483 hlog [-h|--help] [-m|--man] |
371 |
484 |
372 =head1 DESCRIPTION |
485 =head1 DESCRIPTION |
373 |
486 |
374 This script should run as a server providing access to |
487 This script should run as a server providing access to |
375 the last lines of a logfile. It should understand basic HTTP/1.x. |
488 the last lines of a logfile. It understands basic HTTP(S)/1.x. |
376 |
489 |
377 See the L<FILES> section for more information on files. |
490 See the L<FILES> section for more information on files. |
378 |
491 |
379 =head1 OPTIONS |
492 =head1 OPTIONS |
380 |
493 |
382 |
495 |
383 =item B<-a>|B<--address> I<address> |
496 =item B<-a>|B<--address> I<address> |
384 |
497 |
385 The address to listen on. (default: 0.0.0.0) |
498 The address to listen on. (default: 0.0.0.0) |
386 |
499 |
|
500 =item B<--auth>[ I<realm>] | B<--noauth> |
|
501 |
|
502 Do (or do not) authorize all access. Optional you may pass the |
|
503 name of a authentication realm. (default: do, realm is hlog) |
|
504 |
387 =item B<--[no]daemon> |
505 =item B<--[no]daemon> |
388 |
506 |
389 Do (or do not) daemonize. (default: do) |
507 Do (or do not) daemonize. (default: do) |
390 |
508 |
391 =item B<--lines> I<lines> |
509 =item B<--[no]debug> |
392 |
510 |
393 The number of lines to show. (default: 10) |
511 Do (or do not) print debug information to STDOUT/ERR and logfile. (default: dont) |
|
512 |
|
513 =item B<--htpasswd> I<path> |
|
514 |
|
515 Path to alternate htpasswd file (default: htpasswd). |
394 |
516 |
395 =item B<-k>|B<--kill> |
517 =item B<-k>|B<--kill> |
396 |
518 |
397 With this option the corresponding (address/port) process gets killed. |
519 With this option the corresponding (address/port) process gets killed. |
398 (default: off) |
520 (default: off) |
399 |
521 |
|
522 =item B<--lines> I<lines> |
|
523 |
|
524 The number of lines to show. (default: 10) |
|
525 |
400 =item B<-p>|B<--port> I<port> |
526 =item B<-p>|B<--port> I<port> |
401 |
527 |
402 The port to listen on. (default: 8080) |
528 The port to listen on. (default: 8080) |
|
529 |
|
530 =item B<--[no]ssl> |
|
531 |
|
532 Enable (or disable) https connections (default: enabled) |
|
533 |
|
534 =item B<--ssl-cert> I<path> |
|
535 |
|
536 Path to alternate ssl certificate file (default: crt.pem) |
|
537 |
|
538 =item B<--ssl-key> I<path> |
|
539 |
|
540 Path to alternate ssl private key file (default: key.pem) |
403 |
541 |
404 =back |
542 =back |
405 |
543 |
406 =head1 EXAMPLES |
544 =head1 EXAMPLES |
407 |
545 |