package main import ( "bufio" "crypto/tls" "crypto/x509" "fmt" "io" "log" "net" "net/http" "net/url" "os" "path" "regexp" "strconv" "strings" "sync" "time" "unicode/utf8" ) const ( readTimeout = 30 * time.Second urlMaxLength = 1024 plainType = "text/plain; charset=utf-8" geminiType = "text/gemini; charset=utf-8" htmlType = "text/html; charset=utf-8" cssType = "text/css; charset=utf-8" logTimeFormat = "2006-01-02 15:04:05" ) const ( statusInput = 10 statusSensitiveInput = 11 statusSuccess = 20 statusRedirectTemporary = 30 statusRedirectPermanent = 31 statusTemporaryFailure = 40 statusUnavailable = 41 statusCGIError = 42 statusProxyError = 43 statusPermanentFailure = 50 statusNotFound = 51 statusGone = 52 statusProxyRequestRefused = 53 statusBadRequest = 59 ) var slashesRegexp = regexp.MustCompile(`[^\\]\/`) var newLine = "\r\n" var logLock sync.Mutex var indexFiles = []string{"index.gmi", "index.gemini"} func writeHeader(c net.Conn, code int, meta string) int { fmt.Fprintf(c, "%d %s%s", code, meta, newLine) return code } func writeStatus(c net.Conn, code int) int { var meta string switch code { case statusTemporaryFailure: meta = "Temporary failure" case statusProxyError: meta = "Proxy error" case statusBadRequest: meta = "Bad request" case statusNotFound: meta = "Not found" case statusProxyRequestRefused: meta = "Proxy request refused" } writeHeader(c, code, meta) return code } func writeSuccess(c net.Conn, serve *pathConfig, contentType string, size int64) int { // Content type meta := contentType if serve.Type != "" { meta = serve.Type } // Cache if serve.cache != cacheUnset { meta += fmt.Sprintf("; cache=%d", serve.cache) } // Language if serve.Lang != "" { meta += fmt.Sprintf("; lang=%s", serve.Lang) } // Size if !config.DisableSize && size >= 0 { meta += fmt.Sprintf("; size=%d", size) } writeHeader(c, statusSuccess, meta) return statusSuccess } func replaceWithUserInput(command []string, request *url.URL) []string { newCommand := make([]string, len(command)) copy(newCommand, command) for i, piece := range newCommand { if strings.Contains(piece, "$USERINPUT") { requestQuery, err := url.QueryUnescape(request.RawQuery) if err == nil { newCommand[i] = strings.ReplaceAll(piece, "$USERINPUT", requestQuery) } } } return newCommand } func servePath(c *tls.Conn, request *url.URL, serve *pathConfig) (int, int64) { resolvedPath := request.Path requestSplit := strings.Split(request.Path, "/") pathSlashes := len(slashesRegexp.FindAllStringIndex(serve.Path, -1)) if len(serve.Path) > 0 { if serve.Path[0] == '/' { pathSlashes++ // Regexp does not match starting slash } } if len(requestSplit) >= pathSlashes { resolvedPath = strings.Join(requestSplit[pathSlashes:], "/") } if !serve.Hidden { for _, piece := range requestSplit { if len(piece) > 0 && piece[0] == '.' { return writeStatus(c, statusNotFound), -1 } } } var filePath string if serve.Root != "" { root := serve.Root if root[len(root)-1] != '/' { root += "/" } if !serve.SymLinks { for i := range requestSplit[pathSlashes:] { info, err := os.Lstat(path.Join(root, strings.Join(requestSplit[pathSlashes:pathSlashes+i+1], "/"))) if err != nil { return writeStatus(c, statusNotFound), -1 } else if info.Mode()&os.ModeSymlink == os.ModeSymlink { return writeStatus(c, statusTemporaryFailure), -1 } } } filePath = path.Join(root, resolvedPath) } if serve.cmd != nil { requireInput := serve.Input != "" || serve.SensitiveInput != "" if requireInput { newCommand := replaceWithUserInput(serve.cmd, request) if newCommand != nil { return serveCommand(c, serve, request, newCommand) } } return serveCommand(c, serve, request, serve.cmd) } else if serve.Proxy != "" { return serveProxy(c, request, serve.Proxy), -1 } else if serve.FastCGI != "" { if filePath == "" { return writeStatus(c, statusNotFound), -1 } contentType := geminiType if serve.Type != "" { contentType = serve.Type } writeSuccess(c, serve, contentType, -1) serveFastCGI(c, config.fcgiPools[serve.FastCGI], request, filePath) return statusSuccess, -1 } else if serve.Redirect != "" { return writeHeader(c, statusRedirectTemporary, serve.Redirect), -1 } if filePath == "" { return writeStatus(c, statusNotFound), -1 } fi, err := os.Stat(filePath) if err != nil { return writeStatus(c, statusNotFound), -1 } mode := fi.Mode() hasTrailingSlash := len(request.Path) > 0 && request.Path[len(request.Path)-1] == '/' if mode.IsDir() { if !hasTrailingSlash { return writeHeader(c, statusRedirectPermanent, request.String()+"/"), -1 } var found bool for _, indexFile := range indexFiles { _, err := os.Stat(path.Join(filePath, indexFile)) if err == nil || os.IsExist(err) { filePath = path.Join(filePath, indexFile) found = true break } } if !found { if serve.List { return serveDirList(c, serve, request, filePath), -1 } return writeStatus(c, statusNotFound), -1 } } else if hasTrailingSlash && len(request.Path) > 1 { r := request.String() return writeHeader(c, statusRedirectPermanent, r[:len(r)-1]), -1 } serveFile(c, serve, filePath) return statusSuccess, fi.Size() } func handleRequest(c *tls.Conn, request *url.URL, requestData string) (int, int64, string) { if request.Path == "" { // Redirect to / return writeHeader(c, statusRedirectPermanent, requestData+"/"), -1, "" } pathBytes := []byte(request.Path) strippedPath := request.Path if strippedPath[0] == '/' { strippedPath = strippedPath[1:] } var matchedHost bool requestHostname := request.Hostname() for hostname := range config.Hosts { if requestHostname != hostname { continue } matchedHost = true for _, serve := range config.Hosts[hostname].Paths { matchedRegexp := serve.r != nil && serve.r.Match(pathBytes) matchedPrefix := serve.r == nil && strings.HasPrefix(request.Path, serve.Path) if !matchedRegexp && !matchedPrefix { matchedRegexp = serve.r != nil && serve.r.Match(append(pathBytes[:], byte('/'))) matchedPrefix = serve.r == nil && strings.HasPrefix(request.Path+"/", serve.Path) if matchedRegexp || matchedPrefix { newRequest, err := url.Parse(request.String()) if err != nil { return writeStatus(c, statusBadRequest), -1, "" } newRequest.Path += "/" return writeHeader(c, statusRedirectTemporary, newRequest.String()), -1, serve.Log } continue } requireInput := serve.Input != "" || serve.SensitiveInput != "" if request.RawQuery == "" && requireInput { if serve.SensitiveInput != "" { return writeHeader(c, statusSensitiveInput, serve.SensitiveInput), -1, serve.Log } return writeHeader(c, statusInput, serve.Input), -1, serve.Log } if matchedRegexp || matchedPrefix { status, size := servePath(c, request, serve) return status, size, serve.Log } } break } if matchedHost { return writeStatus(c, statusNotFound), -1, "" } return writeStatus(c, statusProxyRequestRefused), -1, "" } func handleConn(c *tls.Conn) { t := time.Now() var request *url.URL var logPath string method := "GET" status := 0 size := int64(-1) protocol := "Gemini" defer func() { if quiet && logPath == "" { return } entry := logEntry(method, protocol, request, status, size, time.Since(t)) if !quiet { log.Println(string(entry)) } if logPath == "" { return } logLock.Lock() defer logLock.Unlock() f, err := os.OpenFile(logPath, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600) if err != nil { log.Printf("ERROR: Failed to open log file at %s: %s", logPath, err) return } defer f.Close() if _, err = f.Write(entry); err != nil { log.Printf("ERROR: Failed to write to log file at %s: %s", logPath, err) return } f.Write([]byte("\n")) }() c.SetReadDeadline(time.Now().Add(readTimeout)) defer c.Close() var dataBuf []byte var buf = make([]byte, 1) var readCR bool if config.SaneEOL { readCR = true } for { n, err := c.Read(buf) if err == io.EOF { break } else if err != nil || n != 1 { if debug { log.Printf("error: failed to read client: %s", err) } return } if buf[0] == '\r' { readCR = true continue } else if readCR && buf[0] == '\n' { break } dataBuf = append(dataBuf, buf[0]) } requestData := string(dataBuf) state := c.ConnectionState() certs := state.PeerCertificates var clientCertKeys [][]byte for _, cert := range certs { pubKey, err := x509.MarshalPKIXPublicKey(cert.PublicKey) if err != nil { continue } clientCertKeys = append(clientCertKeys, pubKey) } if strings.HasPrefix(requestData, "GET ") || strings.HasPrefix(requestData, "HEAD ") { w := newResponseWriter(c) defer w.WriteHeader(http.StatusOK) if config.DisableHTTPS { status = statusProxyRequestRefused http.Error(w, "Error: Proxy request refused", http.StatusBadRequest) return } r, err := http.ReadRequest(bufio.NewReader(io.MultiReader(strings.NewReader(requestData+"\r\n"), c))) if err != nil { status = http.StatusInternalServerError http.Error(w, err.Error(), status) return } if r.Proto == "" { protocol = "HTTP" } else { protocol = r.Proto } r.URL.Scheme = "https" r.URL.Host = strings.ToLower(r.Host) method = r.Method request = r.URL status, size, logPath = serveHTTPS(w, r) return } if len(requestData) > urlMaxLength || !utf8.ValidString(requestData) { status = writeStatus(c, statusBadRequest) return } var err error request, err = url.Parse(requestData) if err != nil { status = writeStatus(c, statusBadRequest) return } request.Host = strings.ToLower(request.Host) requestHostname := request.Hostname() if requestHostname == "" || strings.ContainsRune(requestHostname, ' ') { status = writeStatus(c, statusBadRequest) return } var requestPort int if request.Port() != "" { requestPort, err = strconv.Atoi(request.Port()) if err != nil { requestPort = 0 } } validScheme := request.Scheme == "gemini" || (!config.DisableHTTPS && request.Scheme == "https") if !validScheme || (requestPort > 0 && requestPort != config.port) { status = writeStatus(c, statusProxyRequestRefused) return } status, size, logPath = handleRequest(c, request, requestData) } func logEntry(method string, protocol string, request *url.URL, status int, size int64, elapsed time.Duration) []byte { hostFormatted := "-" pathFormatted := "-" sizeFormatted := "-" if request != nil { if request.Path != "" { pathFormatted = request.Path } if request.Hostname() != "" { hostFormatted = request.Hostname() if request.Port() != "" { hostFormatted += ":" + request.Port() } else { hostFormatted += ":1965" } } } if size >= 0 { sizeFormatted = strconv.FormatInt(size, 10) } return []byte(fmt.Sprintf(`%s - - - [%s] "%s %s %s" %d %s %.4f`, hostFormatted, time.Now().Format(logTimeFormat), method, pathFormatted, protocol, status, sizeFormatted, elapsed.Seconds())) } func handleListener(l net.Listener) { for { conn, err := l.Accept() if err != nil { log.Fatal(err) } go handleConn(conn.(*tls.Conn)) } } func getCertificate(info *tls.ClientHelloInfo) (*tls.Certificate, error) { host := config.Hosts[info.ServerName] if host != nil { return host.cert, nil } for _, host := range config.Hosts { return host.cert, nil } return nil, nil } func listen(address string) { tlsConfig := &tls.Config{ ClientAuth: tls.RequestClientCert, GetCertificate: getCertificate, MinVersion: tls.VersionTLS12, InsecureSkipVerify: true, } listener, err := tls.Listen("tcp", address, tlsConfig) if err != nil { log.Fatalf("failed to listen on %s: %s", address, err) } handleListener(listener) }