Added IP blacklisting.
[scratchy.git] / scratchy.scm
1 ;;; Scratchy gopher server
2 ;;
3 ;; Requires Chicken 5
4 ;;
5
6 ;;; Imports
7
8 (import tcp6
9         (chicken port)
10         (chicken io)
11         (chicken string)
12         (chicken pathname)
13         (chicken file)
14         (chicken time posix)
15         (chicken condition)
16         (chicken process)
17         (chicken process-context)
18         (chicken process-context posix)
19         srfi-1 srfi-13 matchable)
20
21 ;;; Global constants
22
23 (define scratchy-version "1.3.0")
24
25 (define scratchy-footer
26   (conc "\n"
27         "--------------------------------------------------\n"
28         "This gopher hole was dug using Scratchy v" scratchy-version ".\n"
29         "Powered by Chicken Scheme!"))
30
31 (define gopher-index-filename "index")
32
33 ;;; Server loop
34
35 ;; We don't yet use worker threads here to handle requests,
36 ;; the server just blocks until the first request is finished.
37 ;; While we should fix this, it's actually probably okay, as
38 ;; we genuinely don't expect a huge flood of gopher traffic. :-(
39
40 (define-record config
41   root-dir host port display-footer user group blacklist blacklist-resp)
42
43 (define (run-server config)
44   ;; (set-buffering-mode! (current-output-port) #:line)
45   (let ((listener (tcp-listen (config-port config) 10 "::")))
46     (print "Gopher server listening on port " (config-port config) " ...")
47     (drop-privs config)
48     (server-loop listener config))
49   (tcp-close listener))
50
51 (define (drop-privs config)
52   (let ((uid (config-user config))
53         (gid (config-group config)))
54     (if (not (null? gid)) ; Group first, since only root can switch groups.
55         (set! (current-group-id) gid))
56     (if (not (null? uid))
57         (set! (current-user-id) uid))))
58
59 (define (server-loop listener config)
60   (let-values (((in-port out-port) (tcp-accept listener)))
61     (let-values (((local-ip remote-ip) (tcp-addresses in-port)))
62       (print "Accepted connection from " remote-ip
63              " on " (seconds->string))
64       (condition-case
65           (if (or (null? (config-blacklist config))
66                   (not (member remote-ip
67                                (with-input-from-file
68                                    (config-blacklist config)
69                                  read))))
70               (let* ((line (read-line in-port))
71                      (selector-raw (string-trim-both line))
72                      (selector (if (= (string-length selector-raw) 0)
73                                    "/"
74                                    selector-raw)))
75                 (condition-case
76                     (begin
77                       (with-output-to-port out-port
78                         (lambda ()
79                           (serve-selector selector config)))
80                       (print "... served selector '" selector "'. Closing connection."))
81                   (o (exn)
82                      (print-error-message o out-port)
83                      (print-error-message o)
84                      (print "Error while attempting to serve selector " selector "."))))
85               (begin
86                 (print "Connection from blacklisted IP. Closing.")
87                 (with-output-to-port out-port
88                   (lambda ()
89                     (print* "Refusing to serve to IP " remote-ip ".\r\n")
90                     (unless (null? (config-blacklist-resp config))
91                       (for-each (lambda (l) (print* l "\r\n"))
92                                 (with-input-from-file
93                                     (config-blacklist-resp config)
94                                   read-lines)))
95                     (print* ".\r\n")))))
96         (o (exn)
97            (print-error-message o))))
98     (close-input-port in-port)
99     (close-output-port out-port))
100   (server-loop listener config))
101
102 ;;; Selector type inference
103
104 (define (true-for-one? predicate values)
105   (if (null? values)
106       #f
107       (if (predicate (car values))
108           #t
109           (true-for-one? predicate (cdr values)))))
110
111 (define (has-suffix? selector . suffixes)
112   (true-for-one? (lambda (suffix)
113                    (string-suffix? suffix selector))
114                  suffixes))
115
116 (define (has-prefix? selector . prefixes)
117   (true-for-one? (lambda (prefix)
118                    (string-prefix? prefix selector))
119                  prefixes))
120
121 (define (infer-selector-type selector)
122   (let ((l (string-downcase selector)))
123     (cond
124      ((or (= (string-length l) 0)
125           (string-suffix? "/" l)) 1)
126      ((has-suffix? l ".txt" ".org" ".md") 0)
127      ((has-suffix? l ".png" ".jpg" ".gif" ".bmp" ".tif" ".tga") 'I)
128      ((has-suffix? l "?" "%3f") 7)
129      ((has-prefix? l "url:" "/url:") 'h)
130      (else 9))))
131
132
133 ;;; Selector retrieval
134
135 (define (serve-selector raw-selector config)
136   (let* ((selector-list (string-split raw-selector "\t"))
137          (selector (car selector-list))
138          (arguments (cdr selector-list)))
139     (if (string-contains selector "|")
140         (let ((l (string-split selector "|" #t)))
141           (serve-script (car l) (cdr l) config))
142         (case (infer-selector-type selector)
143           ((1) (serve-directory-file selector config))
144           ((7) (let ((l (string-split selector "?" #t)))
145                  (serve-script (car l) arguments config)))
146           ((0) (serve-text-file selector config))
147           ((h) (serve-url selector config))
148           (else (serve-binary-file selector config))))))
149
150 (define (legal-filename? filename config)
151   (and (string-prefix? (config-root-dir config)
152                        (normalize-pathname filename))
153        (file-exists? filename)
154        (not (directory-exists? filename))
155        (file-readable? filename)))
156
157 (define (legal-script-filename? filename config)
158   (and (legal-filename? filename config)
159        (string-suffix? ".scm" filename)
160        (file-executable? filename)))
161
162 (define (serve-directory-file selector config)
163   (let ((filename (make-pathname (list (config-root-dir config) selector)
164                                  gopher-index-filename)))
165     (if (legal-filename? filename config)
166         (begin
167           (with-input-from-file filename
168             (lambda ()
169               (let loop ((c (peek-char)))
170                 (if (eof-object? c)
171                     'done
172                     (begin
173                       (if (eq? c #\,)
174                           (begin
175                             (read-char)
176                             (serve-record (read) selector config)
177                             (read-line))
178                           (serve-info-records (read-line)))
179                       (loop (peek-char)))))))
180           (if (config-display-footer config)
181               (serve-info-records scratchy-footer))
182           (print ".\r"))
183         (error "No legal index file not found."))))
184   
185 (define (serve-text-file selector config)
186   (let ((filename (make-pathname (config-root-dir config) selector)))
187     (if (legal-filename? filename config)
188         (begin
189           (with-input-from-file filename
190             (lambda ()
191               (for-each
192                (lambda (line)
193                  (print line "\r"))
194                (read-lines))))
195           (print ".\r"))
196         (error "File not found." filename))))
197
198 (define (serve-binary-file selector config)
199   (let ((filename (make-pathname (config-root-dir config) selector)))
200     (if (legal-filename? filename config)
201         (with-input-from-file filename
202           (lambda ()
203             (let loop ((b (read-byte)))
204               (if (eof-object? b)
205                   'done
206                   (begin
207                     (write-byte b)
208                     (loop (read-byte)))))))
209         (error "File not found." filename))))
210
211 (define (serve-url selector config)
212   (let ((url (substring selector 4)))
213     (print
214      "<html><head><title>Redirection</title>"
215      "<meta http-equiv=\"refresh\" content=\"10; URL='" url "'\" />"
216      "</head><body>"
217      "<p>If you are seeing this page, your gopher browser does not "
218      "properly support URL directory entries or cannot follow such "
219      "links.</p>"
220      "<p>If you are viewing this page using a web browser, you should "
221      "be redirected shortly.  Otherwise, you can manually open the "
222      "the follwing url:\n"
223      "\n"
224      "<a href=\"" url "\">" url "</a>\n"
225      "</body></html>")))
226
227 (define (serve-script selector arguments config)
228   (let ((filename (make-pathname (config-root-dir config) selector)))
229     (if (legal-script-filename? filename config)
230         (let* ((sexp (with-input-from-file filename read))
231                (script-result (with-selector-dir
232                                selector config
233                                (lambda ()
234                                  (apply (eval sexp) arguments)))))
235           (when (pair? script-result)
236             (serve-records script-result
237                            (pathname-directory selector) config)
238             (print ".\r")))
239         (error "No legal index script not found." filename))))
240
241
242 ;;; Index rendering
243
244 (define (serve-records records dir-selector config)
245   (for-each
246    (lambda (record)
247      (serve-record record dir-selector config))
248    records))
249
250 (define (serve-info-records string)
251   (for-each
252    (lambda (line)
253      (print* "i")
254      (for-each (lambda (char)
255                  (print* (if (eq? char #\tab)
256                              "    "
257                              char)))
258                (string->list line))
259      (print "\tfake\tfake\t1\r"))
260    (string-split string "\n" #t)))
261
262 (define (serve-record record dir-selector config)
263   (match record
264     ((? string?) (serve-info-records record))
265     (('shell command) (serve-shell-command command dir-selector config))
266     (('eval expression) (serve-expression expression dir-selector config))
267     (('url display-string url)
268      (print #\h display-string "\tURL:" url
269             "\t" (config-host config)
270             "\t" (config-port config) "\r"))
271     ((type display-string selector host port)
272      (print type display-string "\t" selector "\t" host "\t" port "\r"))
273     ((type display-string selector host)
274      (serve-record (list type display-string selector host 70)
275                    dir-selector config))
276     ((type display-string selector)
277      (serve-record (list type display-string
278                          (make-pathname dir-selector selector)
279                          (config-host config) (config-port config))
280                    dir-selector config))
281     ((display-string selector)
282      (serve-record (list (infer-selector-type selector) display-string selector)
283                    dir-selector config))
284     ((selector)
285      (serve-record (list (infer-selector-type selector) selector)
286                    dir-selector config))
287     (else (error "Unknown record type."))))
288
289 (define (serve-shell-command command dir-selector config)
290   (with-selector-dir
291    dir-selector config
292    (lambda ()
293      (let-values (((in-port out-port id) (process command)))
294        (let ((string (read-string #f in-port)))
295          (if (and (not (eof-object? string))
296                   (> (string-length string) 0))
297              (serve-info-records (string-chomp string "\n")))
298          (close-input-port in-port)
299          (close-output-port out-port))))))
300
301 (define (serve-expression expression dir-selector config)
302   (with-selector-dir
303    dir-selector config
304    (lambda ()
305      (serve-records (eval expression) dir-selector config))))
306
307
308 ;;; Utility methods
309
310 (define (with-current-working-directory directory thunk)
311   (let ((old-wd (current-directory))
312         (result 'none))
313     (condition-case
314         (begin
315           (change-directory directory)
316           (set! result (thunk))
317           (change-directory old-wd)
318           result)
319       (o (exn)
320          (change-directory old-wd)
321          (signal o)))))
322
323 (define (with-selector-dir selector config thunk)
324   (with-current-working-directory
325    (make-pathname (config-root-dir config)
326                   (pathname-directory selector)) thunk))
327
328
329 ;;; Main
330
331 (define (print-usage progname)
332   (let ((indent-str (make-string (string-length progname) #\space)))
333     (print "Usage:\n"
334            progname " -h/--help\n"
335            progname " [-n/--no-footer] [-u/--user UID] [-g/--group GID]\n"
336            indent-str " [-b/--blacklist FILE] [-r/--blacklist-resp RESPFILE] root-dir hostname [port]\n"
337            "\n"
338            "The -n option tells the server to not display a directory footer.\n"
339            "The -u and -g can be used to set the UID and GID of the process following\n"
340            "the creation of the TCP port listener (which often requires root).\n"
341            "The -b option can be used to specify a FILE containing a list of IP addresses\n"
342            "to block from the server. If a connection from a blocked address is served,\n"
343            "the response file RESPFILE is served instead, if this is provided.")))
344
345 (define (main)
346   (let* ((progname (pathname-file (car (argv))))
347          (config (make-config '() '() 70 #t '() '() '() '())))
348     (if (null? (cdr (argv)))
349         (print-usage progname)
350         (let loop ((args (cdr (argv))))
351           (let ((this-arg (car args))
352                 (rest-args (cdr args)))
353             (if (string-prefix? "-" this-arg)
354                 (cond
355                  ((or (equal? this-arg "-h")
356                       (equal? this-arg "--help"))
357                   (print-usage progname))
358                  ((or (equal? this-arg "-n")
359                       (equal? this-arg "--no-footer"))
360                   (config-display-footer-set! config #f)
361                   (loop rest-args))
362                  ((or (equal? this-arg "-u")
363                       (equal? this-arg "--user"))
364                   (config-user-set! config (string->number (car rest-args)))
365                   (loop (cdr rest-args)))
366                  ((or (equal? this-arg "-g")
367                       (equal? this-arg "--group"))
368                   (config-group-set! config (string->number (car rest-args)))
369                   (loop (cdr rest-args)))
370                  ((or (equal? this-arg "-b")
371                       (equal? this-arg "--blacklist"))
372                   (config-blacklist-set! config (car rest-args))
373                   (loop (cdr rest-args)))
374                  ((or (equal? this-arg "-r")
375                       (equal? this-arg "--blacklist-resp"))
376                   (config-blacklist-resp-set! config (car rest-args))
377                   (loop (cdr rest-args)))
378                  (else
379                   (print-usage progname)))
380                 (begin
381                   (config-root-dir-set! config (car args))
382                   (config-host-set! config (cadr args))
383                   (if (>= (length rest-args) 2)
384                       (config-port-set! config (string->number (caddr args))))
385                   (run-server config))))))))
386
387 (main)
388
389 ;; (define (test)
390 ;;   (run-server (make-config "gopher-root" "localhost" 70 #t '() '() '() '())))
391
392 ;; (test)