source: code/trunk/morty.go@ 45

Last change on this file since 45 was 45, checked in by asciimoo, 9 years ago

Merge pull request #35 from dalf/url_encoding

Fetched URI matched the mortyurl.

File size: 18.0 KB
Line 
1package main
2
3import (
4 "bytes"
5 "crypto/hmac"
6 "crypto/sha256"
7 "encoding/hex"
8 "errors"
9 "flag"
10 "fmt"
11 "io"
12 "log"
13 "net/url"
14 "regexp"
15 "strings"
16 "time"
17
18 "github.com/valyala/fasthttp"
19 "golang.org/x/net/html"
20 "golang.org/x/net/html/charset"
21 "golang.org/x/text/encoding"
22)
23
24const (
25 STATE_DEFAULT int = 0
26 STATE_IN_STYLE int = 1
27 STATE_IN_NOSCRIPT int = 2
28)
29
30var CLIENT *fasthttp.Client = &fasthttp.Client{
31 MaxResponseBodySize: 10 * 1024 * 1024, // 10M
32}
33
34var CSS_URL_REGEXP *regexp.Regexp = regexp.MustCompile("url\\((['\"]?)[ \\t\\f]*([\u0009\u0021\u0023-\u0026\u0028\u002a-\u007E]+)(['\"]?)\\)?")
35
36var UNSAFE_ELEMENTS [][]byte = [][]byte{
37 []byte("applet"),
38 []byte("canvas"),
39 []byte("embed"),
40 //[]byte("iframe"),
41 []byte("script"),
42}
43
44var SAFE_ATTRIBUTES [][]byte = [][]byte{
45 []byte("abbr"),
46 []byte("accesskey"),
47 []byte("align"),
48 []byte("alt"),
49 []byte("as"),
50 []byte("autocomplete"),
51 []byte("charset"),
52 []byte("checked"),
53 []byte("class"),
54 []byte("content"),
55 []byte("contenteditable"),
56 []byte("contextmenu"),
57 []byte("dir"),
58 []byte("for"),
59 []byte("height"),
60 []byte("hidden"),
61 []byte("id"),
62 []byte("lang"),
63 []byte("media"),
64 []byte("method"),
65 []byte("name"),
66 []byte("nowrap"),
67 []byte("placeholder"),
68 []byte("property"),
69 []byte("rel"),
70 []byte("spellcheck"),
71 []byte("tabindex"),
72 []byte("target"),
73 []byte("title"),
74 []byte("translate"),
75 []byte("type"),
76 []byte("value"),
77 []byte("width"),
78}
79
80var SELF_CLOSING_ELEMENTS [][]byte = [][]byte{
81 []byte("area"),
82 []byte("base"),
83 []byte("br"),
84 []byte("col"),
85 []byte("embed"),
86 []byte("hr"),
87 []byte("img"),
88 []byte("input"),
89 []byte("keygen"),
90 []byte("link"),
91 []byte("meta"),
92 []byte("param"),
93 []byte("source"),
94 []byte("track"),
95 []byte("wbr"),
96}
97
98type Proxy struct {
99 Key []byte
100 RequestTimeout time.Duration
101}
102
103type RequestConfig struct {
104 Key []byte
105 BaseURL *url.URL
106}
107
108var HTML_FORM_EXTENSION string = `<input type="hidden" name="mortyurl" value="%s" /><input type="hidden" name="mortyhash" value="%s" />`
109
110var HTML_BODY_EXTENSION string = `
111<div id="mortyheader">
112 <input type="checkbox" id="mortytoggle" autocomplete="off" />
113 <div><p>This is a proxified and sanitized view of the page,<br />visit <a href="%s" rel="noreferrer">original site</a>.</p><p><label for="mortytoggle">hide</label></p></div>
114</div>
115<style>
116#mortyheader { position: fixed; padding: 12px 12px 12px 0; margin: 0; box-sizing: content-box; top: 15%%; left: 0; max-width: 140px; color: #444; overflow: hidden; z-index: 110000; font-size: 12px; line-height: normal; }
117#mortyheader a { color: #3498db; font-weight: bold; }
118#mortyheader p { padding: 0 0 0.7em 0; margin: 0; }
119#mortyheader > div { padding: 8px; font-size: 12px !important; font-family: sans !important; border-width: 4px 4px 4px 0; border-style: solid; border-color: #1abc9c; background: #FFF; line-height: 1em; }
120#mortyheader label { text-align: right; cursor: pointer; display: block; color: #444; padding: 0; margin: 0; }
121input[type=checkbox]#mortytoggle { display: none; }
122input[type=checkbox]#mortytoggle:checked ~ div { display: none; }
123</style>
124`
125
126var HTML_META_CONTENT_TYPE string = "<meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\">"
127
128func (p *Proxy) RequestHandler(ctx *fasthttp.RequestCtx) {
129
130 if appRequestHandler(ctx) {
131 return
132 }
133
134 requestHash := popRequestParam(ctx, []byte("mortyhash"))
135
136 requestURI := popRequestParam(ctx, []byte("mortyurl"))
137
138 if requestURI == nil {
139 p.serveMainPage(ctx, 200, nil)
140 return
141 }
142
143 if p.Key != nil {
144 if !verifyRequestURI(requestURI, requestHash, p.Key) {
145 // HTTP status code 403 : Forbidden
146 p.serveMainPage(ctx, 403, errors.New(`invalid "mortyhash" parameter`))
147 return
148 }
149 }
150
151 parsedURI, err := url.Parse(string(requestURI))
152
153 if strings.HasSuffix(parsedURI.Host, ".onion") {
154 // HTTP status code 501 : Not Implemented
155 p.serveMainPage(ctx, 501, errors.New("Tor urls are not supported yet"))
156 return
157 }
158
159 if err != nil {
160 // HTTP status code 500 : Internal Server Error
161 p.serveMainPage(ctx, 500, err)
162 return
163 }
164
165 req := fasthttp.AcquireRequest()
166 defer fasthttp.ReleaseRequest(req)
167 req.SetConnectionClose()
168
169 requestURIStr := string(requestURI)
170
171 log.Println("getting", requestURIStr)
172
173 req.SetRequestURI(requestURIStr)
174 req.Header.SetUserAgentBytes([]byte("Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.143 Safari/537.36"))
175
176 resp := fasthttp.AcquireResponse()
177 defer fasthttp.ReleaseResponse(resp)
178
179 req.Header.SetMethodBytes(ctx.Method())
180 if ctx.IsPost() || ctx.IsPut() {
181 req.SetBody(ctx.PostBody())
182 }
183
184 err = CLIENT.DoTimeout(req, resp, p.RequestTimeout)
185
186 if err != nil {
187 if err == fasthttp.ErrTimeout {
188 // HTTP status code 504 : Gateway Time-Out
189 p.serveMainPage(ctx, 504, err)
190 } else {
191 // HTTP status code 500 : Internal Server Error
192 p.serveMainPage(ctx, 500, err)
193 }
194 return
195 }
196
197 if resp.StatusCode() != 200 {
198 switch resp.StatusCode() {
199 case 301, 302, 303, 307, 308:
200 loc := resp.Header.Peek("Location")
201 if loc != nil {
202 rc := &RequestConfig{Key: p.Key, BaseURL: parsedURI}
203 url, err := rc.ProxifyURI(string(loc))
204 if err == nil {
205 ctx.SetStatusCode(resp.StatusCode())
206 ctx.Response.Header.Add("Location", url)
207 log.Println("redirect to", string(loc))
208 return
209 }
210 }
211 }
212 error_message := fmt.Sprintf("invalid response: %d (%s)", resp.StatusCode(), requestURIStr)
213 p.serveMainPage(ctx, resp.StatusCode(), errors.New(error_message))
214 return
215 }
216
217 contentType := resp.Header.Peek("Content-Type")
218
219 if contentType == nil {
220 // HTTP status code 503 : Service Unavailable
221 p.serveMainPage(ctx, 503, errors.New("invalid content type"))
222 return
223 }
224
225 if bytes.Contains(bytes.ToLower(contentType), []byte("javascript")) {
226 // HTTP status code 403 : Forbidden
227 p.serveMainPage(ctx, 403, errors.New("forbidden content type"))
228 return
229 }
230
231 contentInfo := bytes.SplitN(contentType, []byte(";"), 2)
232
233 var responseBody []byte
234
235 if len(contentInfo) == 2 && bytes.Contains(contentInfo[0], []byte("text")) {
236 e, ename, _ := charset.DetermineEncoding(resp.Body(), string(contentType))
237 if (e != encoding.Nop) && (!strings.EqualFold("utf-8", ename)) {
238 responseBody, err = e.NewDecoder().Bytes(resp.Body())
239 if err != nil {
240 // HTTP status code 503 : Service Unavailable
241 p.serveMainPage(ctx, 503, err)
242 return
243 }
244 } else {
245 responseBody = resp.Body()
246 }
247 } else {
248 responseBody = resp.Body()
249 }
250
251 ctx.SetContentType(fmt.Sprintf("%s; charset=UTF-8", contentInfo[0]))
252
253 switch {
254 case bytes.Contains(contentType, []byte("css")):
255 sanitizeCSS(&RequestConfig{Key: p.Key, BaseURL: parsedURI}, ctx, responseBody)
256 case bytes.Contains(contentType, []byte("html")):
257 sanitizeHTML(&RequestConfig{Key: p.Key, BaseURL: parsedURI}, ctx, responseBody)
258 default:
259 if ctx.Request.Header.Peek("Content-Disposition") != nil {
260 ctx.Response.Header.AddBytesV("Content-Disposition", ctx.Request.Header.Peek("Content-Disposition"))
261 }
262 ctx.Write(responseBody)
263 }
264}
265
266func appRequestHandler(ctx *fasthttp.RequestCtx) bool {
267 // serve robots.txt
268 if bytes.Equal(ctx.Path(), []byte("/robots.txt")) {
269 ctx.SetContentType("text/plain")
270 ctx.Write([]byte("User-Agent: *\nDisallow: /\n"))
271 return true
272 }
273
274 return false
275}
276
277func popRequestParam(ctx *fasthttp.RequestCtx, paramName []byte) []byte {
278 param := ctx.QueryArgs().PeekBytes(paramName)
279
280 if param == nil {
281 param = ctx.PostArgs().PeekBytes(paramName)
282 if param != nil {
283 ctx.PostArgs().DelBytes(paramName)
284 }
285 } else {
286 ctx.QueryArgs().DelBytes(paramName)
287 }
288
289 return param
290}
291
292func sanitizeCSS(rc *RequestConfig, out io.Writer, css []byte) {
293 // TODO
294
295 urlSlices := CSS_URL_REGEXP.FindAllSubmatchIndex(css, -1)
296
297 if urlSlices == nil {
298 out.Write(css)
299 return
300 }
301
302 startIndex := 0
303
304 for _, s := range urlSlices {
305 urlStart := s[4]
306 urlEnd := s[5]
307
308 if uri, err := rc.ProxifyURI(string(css[urlStart:urlEnd])); err == nil {
309 out.Write(css[startIndex:urlStart])
310 out.Write([]byte(uri))
311 startIndex = urlEnd
312 } else {
313 log.Println("cannot proxify css uri:", string(css[urlStart:urlEnd]))
314 }
315 }
316 if startIndex < len(css) {
317 out.Write(css[startIndex:len(css)])
318 }
319}
320
321func sanitizeHTML(rc *RequestConfig, out io.Writer, htmlDoc []byte) {
322 r := bytes.NewReader(htmlDoc)
323 decoder := html.NewTokenizer(r)
324 decoder.AllowCDATA(true)
325
326 unsafeElements := make([][]byte, 0, 8)
327 state := STATE_DEFAULT
328 for {
329 token := decoder.Next()
330 if token == html.ErrorToken {
331 err := decoder.Err()
332 if err != io.EOF {
333 log.Println("failed to parse HTML:")
334 }
335 break
336 }
337
338 if len(unsafeElements) == 0 {
339
340 switch token {
341 case html.StartTagToken, html.SelfClosingTagToken:
342 tag, hasAttrs := decoder.TagName()
343 safe := !inArray(tag, UNSAFE_ELEMENTS)
344 if !safe {
345 if !inArray(tag, SELF_CLOSING_ELEMENTS) {
346 var unsafeTag []byte = make([]byte, len(tag))
347 copy(unsafeTag, tag)
348 unsafeElements = append(unsafeElements, unsafeTag)
349 }
350 break
351 }
352 if bytes.Equal(tag, []byte("base")) {
353 for {
354 attrName, attrValue, moreAttr := decoder.TagAttr()
355 if bytes.Equal(attrName, []byte("href")) {
356 parsedURI, err := url.Parse(string(attrValue))
357 if err == nil {
358 rc.BaseURL = parsedURI
359 }
360 }
361 if !moreAttr {
362 break
363 }
364 }
365 break
366 }
367 if bytes.Equal(tag, []byte("noscript")) {
368 state = STATE_IN_NOSCRIPT
369 break
370 }
371 var attrs [][][]byte
372 if hasAttrs {
373 for {
374 attrName, attrValue, moreAttr := decoder.TagAttr()
375 attrs = append(attrs, [][]byte{
376 attrName,
377 attrValue,
378 []byte(html.EscapeString(string(attrValue))),
379 })
380 if !moreAttr {
381 break
382 }
383 }
384 }
385 if bytes.Equal(tag, []byte("link")) {
386 sanitizeLinkTag(rc, out, attrs)
387 break
388 }
389
390 if bytes.Equal(tag, []byte("meta")) {
391 sanitizeMetaTag(rc, out, attrs)
392 break
393 }
394
395 fmt.Fprintf(out, "<%s", tag)
396
397 if hasAttrs {
398 sanitizeAttrs(rc, out, attrs)
399 }
400
401 if token == html.SelfClosingTagToken {
402 fmt.Fprintf(out, " />")
403 } else {
404 fmt.Fprintf(out, ">")
405 if bytes.Equal(tag, []byte("style")) {
406 state = STATE_IN_STYLE
407 }
408 }
409
410 if bytes.Equal(tag, []byte("head")) {
411 fmt.Fprintf(out, HTML_META_CONTENT_TYPE)
412 }
413
414 if bytes.Equal(tag, []byte("form")) {
415 var formURL *url.URL
416 for _, attr := range attrs {
417 if bytes.Equal(attr[0], []byte("action")) {
418 formURL, _ = url.Parse(string(attr[1]))
419 formURL = mergeURIs(rc.BaseURL, formURL)
420 break
421 }
422 }
423 if formURL == nil {
424 formURL = rc.BaseURL
425 }
426 urlStr := formURL.String()
427 var key string
428 if rc.Key != nil {
429 key = hash(urlStr, rc.Key)
430 }
431 fmt.Fprintf(out, HTML_FORM_EXTENSION, urlStr, key)
432
433 }
434
435 case html.EndTagToken:
436 tag, _ := decoder.TagName()
437 writeEndTag := true
438 switch string(tag) {
439 case "body":
440 fmt.Fprintf(out, HTML_BODY_EXTENSION, rc.BaseURL.String())
441 case "style":
442 state = STATE_DEFAULT
443 case "noscript":
444 state = STATE_DEFAULT
445 writeEndTag = false
446 }
447 // skip noscript tags - only the tag, not the content, because javascript is sanitized
448 if writeEndTag {
449 fmt.Fprintf(out, "</%s>", tag)
450 }
451
452 case html.TextToken:
453 switch state {
454 case STATE_DEFAULT:
455 fmt.Fprintf(out, "%s", decoder.Raw())
456 case STATE_IN_STYLE:
457 sanitizeCSS(rc, out, decoder.Raw())
458 case STATE_IN_NOSCRIPT:
459 sanitizeHTML(rc, out, decoder.Raw())
460 }
461
462 case html.DoctypeToken, html.CommentToken:
463 out.Write(decoder.Raw())
464 }
465 } else {
466 switch token {
467 case html.StartTagToken:
468 tag, _ := decoder.TagName()
469 if inArray(tag, UNSAFE_ELEMENTS) {
470 unsafeElements = append(unsafeElements, tag)
471 }
472
473 case html.EndTagToken:
474 tag, _ := decoder.TagName()
475 if bytes.Equal(unsafeElements[len(unsafeElements)-1], tag) {
476 unsafeElements = unsafeElements[:len(unsafeElements)-1]
477 }
478 }
479 }
480 }
481}
482
483func sanitizeLinkTag(rc *RequestConfig, out io.Writer, attrs [][][]byte) {
484 exclude := false
485 for _, attr := range attrs {
486 attrName := attr[0]
487 attrValue := attr[1]
488 if bytes.Equal(attrName, []byte("rel")) {
489 if bytes.Equal(attrValue, []byte("dns-prefetch")) {
490 exclude = true
491 break
492 }
493 }
494 if bytes.Equal(attrName, []byte("as")) {
495 if bytes.Equal(attrValue, []byte("script")) {
496 exclude = true
497 break
498 }
499 }
500 }
501
502 if !exclude {
503 out.Write([]byte("<link"))
504 for _, attr := range attrs {
505 sanitizeAttr(rc, out, attr[0], attr[1], attr[2])
506 }
507 out.Write([]byte(">"))
508 }
509}
510
511func sanitizeMetaTag(rc *RequestConfig, out io.Writer, attrs [][][]byte) {
512 var http_equiv []byte
513 var content []byte
514
515 for _, attr := range attrs {
516 attrName := attr[0]
517 attrValue := attr[1]
518 if bytes.Equal(attrName, []byte("http-equiv")) {
519 http_equiv = bytes.ToLower(attrValue)
520 }
521 if bytes.Equal(attrName, []byte("content")) {
522 content = attrValue
523 }
524 if bytes.Equal(attrName, []byte("charset")) {
525 // exclude <meta charset="...">
526 return
527 }
528 }
529
530 if bytes.Equal(http_equiv, []byte("content-type")) {
531 return
532 }
533
534 out.Write([]byte("<meta"))
535 urlIndex := bytes.Index(bytes.ToLower(content), []byte("url="))
536 if bytes.Equal(http_equiv, []byte("refresh")) && urlIndex != -1 {
537 contentUrl := content[urlIndex+4:]
538 // special case of <meta http-equiv="refresh" content="0; url='example.com/url.with.quote.outside'">
539 if len(contentUrl) >= 2 && (contentUrl[0] == byte('\'') || contentUrl[0] == byte('"')) {
540 if contentUrl[0] == contentUrl[len(contentUrl)-1] {
541 contentUrl = contentUrl[1 : len(contentUrl)-1]
542 }
543 }
544 // output proxify result
545 if uri, err := rc.ProxifyURI(string(contentUrl)); err == nil {
546 fmt.Fprintf(out, ` http-equiv="refresh" content="%surl=%s"`, content[:urlIndex], uri)
547 }
548 } else {
549 sanitizeAttrs(rc, out, attrs)
550 }
551 out.Write([]byte(">"))
552}
553
554func sanitizeAttrs(rc *RequestConfig, out io.Writer, attrs [][][]byte) {
555 for _, attr := range attrs {
556 sanitizeAttr(rc, out, attr[0], attr[1], attr[2])
557 }
558}
559
560func sanitizeAttr(rc *RequestConfig, out io.Writer, attrName, attrValue, escapedAttrValue []byte) {
561 if inArray(attrName, SAFE_ATTRIBUTES) {
562 fmt.Fprintf(out, " %s=\"%s\"", attrName, escapedAttrValue)
563 return
564 }
565 switch string(attrName) {
566 case "src", "href", "action":
567 if uri, err := rc.ProxifyURI(string(attrValue)); err == nil {
568 fmt.Fprintf(out, " %s=\"%s\"", attrName, uri)
569 } else {
570 log.Println("cannot proxify uri:", string(attrValue))
571 }
572 case "style":
573 cssAttr := bytes.NewBuffer(nil)
574 sanitizeCSS(rc, cssAttr, attrValue)
575 fmt.Fprintf(out, " %s=\"%s\"", attrName, html.EscapeString(string(cssAttr.Bytes())))
576 }
577}
578
579func mergeURIs(u1, u2 *url.URL) *url.URL {
580 return u1.ResolveReference(u2)
581}
582
583func (rc *RequestConfig) ProxifyURI(uri string) (string, error) {
584 // remove javascript protocol
585 if strings.HasPrefix(uri, "javascript:") {
586 return "", nil
587 }
588 // TODO check malicious data: - e.g. data:script
589 if strings.HasPrefix(uri, "data:") {
590 return uri, nil
591 }
592
593 if len(uri) > 0 && uri[0] == '#' {
594 return uri, nil
595 }
596
597 u, err := url.Parse(uri)
598 if err != nil {
599 return "", err
600 }
601 u = mergeURIs(rc.BaseURL, u)
602
603 uri = u.String()
604
605 if rc.Key == nil {
606 return fmt.Sprintf("./?mortyurl=%s", url.QueryEscape(uri)), nil
607 }
608
609 return fmt.Sprintf("./?mortyhash=%s&mortyurl=%s", hash(uri, rc.Key), url.QueryEscape(uri)), nil
610}
611
612func inArray(b []byte, a [][]byte) bool {
613 for _, b2 := range a {
614 if bytes.Equal(b, b2) {
615 return true
616 }
617 }
618 return false
619}
620
621func hash(msg string, key []byte) string {
622 mac := hmac.New(sha256.New, key)
623 mac.Write([]byte(msg))
624 return hex.EncodeToString(mac.Sum(nil))
625}
626
627func verifyRequestURI(uri, hashMsg, key []byte) bool {
628 h := make([]byte, hex.DecodedLen(len(hashMsg)))
629 _, err := hex.Decode(h, hashMsg)
630 if err != nil {
631 log.Println("hmac error:", err)
632 return false
633 }
634 mac := hmac.New(sha256.New, key)
635 mac.Write(uri)
636 return hmac.Equal(h, mac.Sum(nil))
637}
638
639func (p *Proxy) serveMainPage(ctx *fasthttp.RequestCtx, statusCode int, err error) {
640 ctx.SetContentType("text/html")
641 ctx.SetStatusCode(statusCode)
642 ctx.Write([]byte(`<!doctype html>
643<head>
644<title>MortyProxy</title>
645<meta name="viewport" content="width=device-width, initial-scale=1 , maximum-scale=1.0, user-scalable=1" />
646<style>
647html { height: 100%; }
648body { min-height : 100%; display: flex; flex-direction:column; font-family: 'Garamond', 'Georgia', serif; text-align: center; color: #444; background: #FAFAFA; margin: 0; padding: 0; font-size: 1.1em; }
649input { border: 1px solid #888; padding: 0.3em; color: #444; background: #FFF; font-size: 1.1em; }
650input[placeholder] { width:80%; }
651a { text-decoration: none; #2980b9; }
652h1, h2 { font-weight: 200; margin-bottom: 2rem; }
653h1 { font-size: 3em; }
654.container { flex:1; min-height: 100%; margin-bottom: 1em; }
655.footer { margin: 1em; }
656.footer p { font-size: 0.8em; }
657</style>
658</head>
659<body>
660 <div class="container">
661 <h1>MortyProxy</h1>
662`))
663 if err != nil {
664 log.Println("error:", err)
665 ctx.Write([]byte("<h2>Error: "))
666 ctx.Write([]byte(html.EscapeString(err.Error())))
667 ctx.Write([]byte("</h2>"))
668 }
669 if p.Key == nil {
670 ctx.Write([]byte(`
671 <form action="post">
672 Visit url: <input placeholder="https://url.." name="mortyurl" autofocus />
673 <input type="submit" value="go" />
674 </form>`))
675 } else {
676 ctx.Write([]byte(`<h3>Warning! This instance does not support direct URL opening.</h3>`))
677 }
678 ctx.Write([]byte(`
679 </div>
680 <div class="footer">
681 <p>Morty rewrites web pages to exclude malicious HTML tags and CSS/HTML attributes. It also replaces external resource references to prevent third-party information leaks.<br />
682 <a href="https://github.com/asciimoo/morty">view on github</a>
683 </p>
684 </div>
685</body>
686</html>`))
687}
688
689func main() {
690
691 listen := flag.String("listen", "127.0.0.1:3000", "Listen address")
692 key := flag.String("key", "", "HMAC url validation key (hexadecimal encoded) - leave blank to disable")
693 ipv6 := flag.Bool("ipv6", false, "Allow IPv6 HTTP requests")
694 requestTimeout := flag.Uint("timeout", 2, "Request timeout")
695 flag.Parse()
696
697 if *ipv6 {
698 CLIENT.Dial = fasthttp.DialDualStack
699 }
700
701 p := &Proxy{RequestTimeout: time.Duration(*requestTimeout) * time.Second}
702
703 if *key != "" {
704 p.Key = []byte(*key)
705 }
706
707 log.Println("listening on", *listen)
708
709 if err := fasthttp.ListenAndServe(*listen, p.RequestHandler); err != nil {
710 log.Fatal("Error in ListenAndServe:", err)
711 }
712}
Note: See TracBrowser for help on using the repository browser.