changeset 3:eb705d4cdcd7

fix fcgi
author Atarwn Gard <a@qwa.su>
date Mon, 09 Mar 2026 02:16:06 +0500
parents d19133be91ba
children dacc92aae6d5
files fcgi/fcgi.go main.go
diffstat 2 files changed, 527 insertions(+), 169 deletions(-) [+]
line wrap: on
line diff
--- a/fcgi/fcgi.go	Mon Mar 09 01:55:11 2026 +0500
+++ b/fcgi/fcgi.go	Mon Mar 09 02:16:06 2026 +0500
@@ -1,195 +1,515 @@
-// Package fcgi implements a minimal FastCGI client.
-//
-// Supports sending a single request over a pre-dialed net.Conn and streaming
-// the CGI response back to an http.ResponseWriter.
+// Copyright 2012 Junqing Tan <ivan@mysqlab.net> and The Go Authors
+// Use of this source code is governed by a BSD-style
+// Part of source code is from Go fcgi package
+
 package fcgi
 
 import (
 	"bufio"
+	"bytes"
 	"encoding/binary"
+	"errors"
+	"fmt"
 	"io"
+	"io/ioutil"
+	"mime/multipart"
+	"net"
 	"net/http"
+	"net/http/httputil"
 	"net/textproto"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strconv"
 	"strings"
+	"sync"
+	"time"
 )
 
-// Reference: https://fastcgi-archives.github.io/FastCGI_Specification.html
+const FCGI_LISTENSOCK_FILENO uint8 = 0
+const FCGI_HEADER_LEN uint8 = 8
+const VERSION_1 uint8 = 1
+const FCGI_NULL_REQUEST_ID uint8 = 0
+const FCGI_KEEP_CONN uint8 = 1
+const doubleCRLF = "\r\n\r\n"
+
+const (
+	FCGI_BEGIN_REQUEST uint8 = iota + 1
+	FCGI_ABORT_REQUEST
+	FCGI_END_REQUEST
+	FCGI_PARAMS
+	FCGI_STDIN
+	FCGI_STDOUT
+	FCGI_STDERR
+	FCGI_DATA
+	FCGI_GET_VALUES
+	FCGI_GET_VALUES_RESULT
+	FCGI_UNKNOWN_TYPE
+	FCGI_MAXTYPE = FCGI_UNKNOWN_TYPE
+)
+
+const (
+	FCGI_RESPONDER uint8 = iota + 1
+	FCGI_AUTHORIZER
+	FCGI_FILTER
+)
+
+const (
+	FCGI_REQUEST_COMPLETE uint8 = iota
+	FCGI_CANT_MPX_CONN
+	FCGI_OVERLOADED
+	FCGI_UNKNOWN_ROLE
+)
+
+const (
+	FCGI_MAX_CONNS  string = "MAX_CONNS"
+	FCGI_MAX_REQS   string = "MAX_REQS"
+	FCGI_MPXS_CONNS string = "MPXS_CONNS"
+)
 
 const (
-	fcgiVersion       = 1
-	fcgiBeginRequest  = 1
-	fcgiParams        = 4
-	fcgiStdin         = 5
-	fcgiStdout        = 6
-	fcgiEndRequest    = 3
-	fcgiRoleResponder = 1
-	fcgiRequestID     = 1
+	maxWrite = 65500 // 65530 may work, but for compatibility
+	maxPad   = 255
 )
 
-// Do sends params and the request body over conn as a FastCGI request, then
-// parses the response and writes it to w. conn is closed by the caller.
-func Do(w http.ResponseWriter, r *http.Request, conn io.ReadWriter, params map[string]string) error {
-	bw := bufio.NewWriter(conn)
+type header struct {
+	Version       uint8
+	Type          uint8
+	Id            uint16
+	ContentLength uint16
+	PaddingLength uint8
+	Reserved      uint8
+}
+
+// for padding so we don't have to allocate all the time
+// not synchronized because we don't care what the contents are
+var pad [maxPad]byte
+
+func (h *header) init(recType uint8, reqId uint16, contentLength int) {
+	h.Version = 1
+	h.Type = recType
+	h.Id = reqId
+	h.ContentLength = uint16(contentLength)
+	h.PaddingLength = uint8(-contentLength & 7)
+}
+
+type record struct {
+	h    header
+	rbuf []byte
+}
 
-	if err := writeRecord(bw, fcgiBeginRequest, fcgiRequestID,
-		[]byte{0, fcgiRoleResponder, 0, 0, 0, 0, 0, 0}); err != nil {
-		return err
+func (rec *record) read(r io.Reader) (buf []byte, err error) {
+	if err = binary.Read(r, binary.BigEndian, &rec.h); err != nil {
+		return
+	}
+	if rec.h.Version != 1 {
+		err = errors.New("fcgi: invalid header version")
+		return
+	}
+	if rec.h.Type == FCGI_END_REQUEST {
+		err = io.EOF
+		return
+	}
+	n := int(rec.h.ContentLength) + int(rec.h.PaddingLength)
+	if len(rec.rbuf) < n {
+		rec.rbuf = make([]byte, n)
+	}
+	if n, err = io.ReadFull(r, rec.rbuf[:n]); err != nil {
+		return
+	}
+	buf = rec.rbuf[:int(rec.h.ContentLength)]
+
+	return
+}
+
+type FCGIClient struct {
+	mutex     sync.Mutex
+	rwc       io.ReadWriteCloser
+	h         header
+	buf       bytes.Buffer
+	keepAlive bool
+	reqId     uint16
+}
+
+// Connects to the fcgi responder at the specified network address.
+// See func net.Dial for a description of the network and address parameters.
+func Dial(network, address string) (fcgi *FCGIClient, err error) {
+	var conn net.Conn
+
+	conn, err = net.Dial(network, address)
+	if err != nil {
+		return
 	}
 
-	if err := writeRecord(bw, fcgiParams, fcgiRequestID, encodeParams(params)); err != nil {
-		return err
+	fcgi = &FCGIClient{
+		rwc:       conn,
+		keepAlive: false,
+		reqId:     1,
 	}
-	if err := writeRecord(bw, fcgiParams, fcgiRequestID, nil); err != nil {
-		return err
+
+	return
+}
+
+// Connects to the fcgi responder at the specified network address with timeout
+// See func net.DialTimeout for a description of the network, address and timeout parameters.
+func DialTimeout(network, address string, timeout time.Duration) (fcgi *FCGIClient, err error) {
+
+	var conn net.Conn
+
+	conn, err = net.DialTimeout(network, address, timeout)
+	if err != nil {
+		return
+	}
+
+	fcgi = &FCGIClient{
+		rwc:       conn,
+		keepAlive: false,
+		reqId:     1,
 	}
 
-	if r.Body != nil {
-		buf := make([]byte, 4096)
-		for {
-			n, err := r.Body.Read(buf)
-			if n > 0 {
-				if werr := writeRecord(bw, fcgiStdin, fcgiRequestID, buf[:n]); werr != nil {
-					return werr
-				}
-			}
-			if err == io.EOF {
-				break
-			}
-			if err != nil {
-				return err
-			}
-		}
+	return
+}
+
+// Close fcgi connnection
+func (this *FCGIClient) Close() {
+	this.rwc.Close()
+}
+
+func (this *FCGIClient) writeRecord(recType uint8, content []byte) (err error) {
+	this.mutex.Lock()
+	defer this.mutex.Unlock()
+	this.buf.Reset()
+	this.h.init(recType, this.reqId, len(content))
+	if err := binary.Write(&this.buf, binary.BigEndian, this.h); err != nil {
+		return err
 	}
-	if err := writeRecord(bw, fcgiStdin, fcgiRequestID, nil); err != nil {
+	if _, err := this.buf.Write(content); err != nil {
+		return err
+	}
+	if _, err := this.buf.Write(pad[:this.h.PaddingLength]); err != nil {
 		return err
 	}
-	if err := bw.Flush(); err != nil {
+	_, err = this.rwc.Write(this.buf.Bytes())
+	return err
+}
+
+func (this *FCGIClient) writeBeginRequest(role uint16, flags uint8) error {
+	b := [8]byte{byte(role >> 8), byte(role), flags}
+	return this.writeRecord(FCGI_BEGIN_REQUEST, b[:])
+}
+
+func (this *FCGIClient) writeEndRequest(appStatus int, protocolStatus uint8) error {
+	b := make([]byte, 8)
+	binary.BigEndian.PutUint32(b, uint32(appStatus))
+	b[4] = protocolStatus
+	return this.writeRecord(FCGI_END_REQUEST, b)
+}
+
+func (this *FCGIClient) writePairs(recType uint8, pairs map[string]string) error {
+	w := newWriter(this, recType)
+	b := make([]byte, 8)
+	nn := 0
+	for k, v := range pairs {
+		m := 8 + len(k) + len(v)
+		if m > maxWrite {
+			// param data size exceed 65535 bytes"
+			vl := maxWrite - 8 - len(k)
+			v = v[:vl]
+		}
+		n := encodeSize(b, uint32(len(k)))
+		n += encodeSize(b[n:], uint32(len(v)))
+		m = n + len(k) + len(v)
+		if (nn + m) > maxWrite {
+			w.Flush()
+			nn = 0
+		}
+		nn += m
+		if _, err := w.Write(b[:n]); err != nil {
+			return err
+		}
+		if _, err := w.WriteString(k); err != nil {
+			return err
+		}
+		if _, err := w.WriteString(v); err != nil {
+			return err
+		}
+	}
+	w.Close()
+	return nil
+}
+
+func readSize(s []byte) (uint32, int) {
+	if len(s) == 0 {
+		return 0, 0
+	}
+	size, n := uint32(s[0]), 1
+	if size&(1<<7) != 0 {
+		if len(s) < 4 {
+			return 0, 0
+		}
+		n = 4
+		size = binary.BigEndian.Uint32(s)
+		size &^= 1 << 31
+	}
+	return size, n
+}
+
+func readString(s []byte, size uint32) string {
+	if size > uint32(len(s)) {
+		return ""
+	}
+	return string(s[:size])
+}
+
+func encodeSize(b []byte, size uint32) int {
+	if size > 127 {
+		size |= 1 << 31
+		binary.BigEndian.PutUint32(b, size)
+		return 4
+	}
+	b[0] = byte(size)
+	return 1
+}
+
+// bufWriter encapsulates bufio.Writer but also closes the underlying stream when
+// Closed.
+type bufWriter struct {
+	closer io.Closer
+	*bufio.Writer
+}
+
+func (w *bufWriter) Close() error {
+	if err := w.Writer.Flush(); err != nil {
+		w.closer.Close()
 		return err
 	}
+	return w.closer.Close()
+}
 
-	// Read response
-	var stdout strings.Builder
-	br := bufio.NewReader(conn)
+func newWriter(c *FCGIClient, recType uint8) *bufWriter {
+	s := &streamWriter{c: c, recType: recType}
+	w := bufio.NewWriterSize(s, maxWrite)
+	return &bufWriter{s, w}
+}
 
-	for {
-		hdr := make([]byte, 8)
-		if _, err := io.ReadFull(br, hdr); err != nil {
-			return err
+// streamWriter abstracts out the separation of a stream into discrete records.
+// It only writes maxWrite bytes at a time.
+type streamWriter struct {
+	c       *FCGIClient
+	recType uint8
+}
+
+func (w *streamWriter) Write(p []byte) (int, error) {
+	nn := 0
+	for len(p) > 0 {
+		n := len(p)
+		if n > maxWrite {
+			n = maxWrite
 		}
-		recType := hdr[1]
-		contentLen := int(binary.BigEndian.Uint16(hdr[4:6]))
-		paddingLen := int(hdr[6])
+		if err := w.c.writeRecord(w.recType, p[:n]); err != nil {
+			return nn, err
+		}
+		nn += n
+		p = p[n:]
+	}
+	return nn, nil
+}
+
+func (w *streamWriter) Close() error {
+	// send empty record to close the stream
+	return w.c.writeRecord(w.recType, nil)
+}
 
-		content := make([]byte, contentLen)
-		if _, err := io.ReadFull(br, content); err != nil {
-			return err
-		}
-		if paddingLen > 0 {
-			if _, err := io.ReadFull(br, make([]byte, paddingLen)); err != nil {
-				return err
+type streamReader struct {
+	c   *FCGIClient
+	buf []byte
+}
+
+func (w *streamReader) Read(p []byte) (n int, err error) {
+
+	if len(p) > 0 {
+		if len(w.buf) == 0 {
+			rec := &record{}
+			w.buf, err = rec.read(w.c.rwc)
+			if err != nil {
+				return
 			}
 		}
 
-		switch recType {
-		case fcgiStdout:
-			stdout.Write(content)
-		case fcgiEndRequest:
-			return forwardResponse(w, stdout.String())
+		n = len(p)
+		if n > len(w.buf) {
+			n = len(w.buf)
 		}
-		// fcgiStderr is silently discarded
+		copy(p, w.buf[:n])
+		w.buf = w.buf[n:]
 	}
+
+	return
 }
 
-func forwardResponse(w http.ResponseWriter, raw string) error {
-	sep := "\r\n\r\n"
-	idx := strings.Index(raw, sep)
-	advance := 4
-	if idx == -1 {
-		sep = "\n\n"
-		idx = strings.Index(raw, sep)
-		advance = 2
-	}
-	if idx == -1 {
-		w.Write([]byte(raw))
-		return nil
+// Do made the request and returns a io.Reader that translates the data read
+// from fcgi responder out of fcgi packet before returning it.
+func (this *FCGIClient) Do(p map[string]string, req io.Reader) (r io.Reader, err error) {
+	err = this.writeBeginRequest(uint16(FCGI_RESPONDER), 0)
+	if err != nil {
+		return
 	}
 
-	headerPart := raw[:idx]
-	body := raw[idx+advance:]
-
-	tp := textproto.NewReader(bufio.NewReader(strings.NewReader(headerPart + "\r\n\r\n")))
-	mime, _ := tp.ReadMIMEHeader()
-
-	status := 200
-	if s := mime.Get("Status"); s != "" {
-		code, _, _ := strings.Cut(s, " ")
-		if n := parseIntFast(code); n > 0 {
-			status = n
-		}
-		mime.Del("Status")
+	err = this.writePairs(FCGI_PARAMS, p)
+	if err != nil {
+		return
 	}
 
-	for k, vs := range mime {
-		for _, v := range vs {
-			w.Header().Add(k, v)
-		}
+	body := newWriter(this, FCGI_STDIN)
+	if req != nil {
+		io.Copy(body, req)
 	}
-	w.WriteHeader(status)
-	w.Write([]byte(body))
-	return nil
+	body.Close()
+
+	r = &streamReader{c: this}
+	return
+}
+
+type badStringError struct {
+	what string
+	str  string
 }
 
-func writeRecord(w io.Writer, recType uint8, reqID uint16, content []byte) error {
-	length := len(content)
-	padding := (8 - (length % 8)) % 8
-	hdr := []byte{
-		fcgiVersion, recType,
-		byte(reqID >> 8), byte(reqID),
-		byte(length >> 8), byte(length),
-		byte(padding), 0,
+func (e *badStringError) Error() string { return fmt.Sprintf("%s %q", e.what, e.str) }
+
+// Request returns a HTTP Response with Header and Body
+// from fcgi responder
+func (this *FCGIClient) Request(p map[string]string, req io.Reader) (resp *http.Response, err error) {
+
+	r, err := this.Do(p, req)
+	if err != nil {
+		return
 	}
-	if _, err := w.Write(hdr); err != nil {
-		return err
+
+	rb := bufio.NewReader(r)
+	tp := textproto.NewReader(rb)
+	resp = new(http.Response)
+	// Parse the first line of the response.
+	line, err := tp.ReadLine()
+	if err != nil {
+		if err == io.EOF {
+			err = io.ErrUnexpectedEOF
+		}
+		return nil, err
+	}
+	if i := strings.IndexByte(line, ' '); i == -1 {
+		err = &badStringError{"malformed HTTP response", line}
+	} else {
+		resp.Proto = line[:i]
+		resp.Status = strings.TrimLeft(line[i+1:], " ")
 	}
-	if len(content) > 0 {
-		if _, err := w.Write(content); err != nil {
-			return err
-		}
+	statusCode := resp.Status
+	if i := strings.IndexByte(resp.Status, ' '); i != -1 {
+		statusCode = resp.Status[:i]
+	}
+	if len(statusCode) != 3 {
+		err = &badStringError{"malformed HTTP status code", statusCode}
+	}
+	resp.StatusCode, err = strconv.Atoi(statusCode)
+	if err != nil || resp.StatusCode < 0 {
+		err = &badStringError{"malformed HTTP status code", statusCode}
+	}
+	var ok bool
+	if resp.ProtoMajor, resp.ProtoMinor, ok = http.ParseHTTPVersion(resp.Proto); !ok {
+		err = &badStringError{"malformed HTTP version", resp.Proto}
 	}
-	if padding > 0 {
-		if _, err := w.Write(make([]byte, padding)); err != nil {
-			return err
+	// Parse the response headers.
+	mimeHeader, err := tp.ReadMIMEHeader()
+	if err != nil {
+		if err == io.EOF {
+			err = io.ErrUnexpectedEOF
 		}
+		return nil, err
 	}
-	return nil
+	resp.Header = http.Header(mimeHeader)
+	// TODO: fixTransferEncoding ?
+	resp.TransferEncoding = resp.Header["Transfer-Encoding"]
+	resp.ContentLength, _ = strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64)
+
+	if chunked(resp.TransferEncoding) {
+		resp.Body = ioutil.NopCloser(httputil.NewChunkedReader(rb))
+	} else {
+		resp.Body = ioutil.NopCloser(rb)
+	}
+	return
 }
 
-func encodeParams(params map[string]string) []byte {
-	var buf []byte
-	for k, v := range params {
-		buf = appendLen(buf, len(k))
-		buf = appendLen(buf, len(v))
-		buf = append(buf, k...)
-		buf = append(buf, v...)
+// Get issues a GET request to the fcgi responder.
+func (this *FCGIClient) Get(p map[string]string) (resp *http.Response, err error) {
+
+	p["REQUEST_METHOD"] = "GET"
+	p["CONTENT_LENGTH"] = "0"
+
+	return this.Request(p, nil)
+}
+
+// Get issues a Post request to the fcgi responder. with request body
+// in the format that bodyType specified
+func (this *FCGIClient) Post(p map[string]string, bodyType string, body io.Reader, l int) (resp *http.Response, err error) {
+
+	if len(p["REQUEST_METHOD"]) == 0 || p["REQUEST_METHOD"] == "GET" {
+		p["REQUEST_METHOD"] = "POST"
 	}
-	return buf
+	p["CONTENT_LENGTH"] = strconv.Itoa(l)
+	if len(bodyType) > 0 {
+		p["CONTENT_TYPE"] = bodyType
+	} else {
+		p["CONTENT_TYPE"] = "application/x-www-form-urlencoded"
+	}
+
+	return this.Request(p, body)
+}
+
+// PostForm issues a POST to the fcgi responder, with form
+// as a string key to a list values (url.Values)
+func (this *FCGIClient) PostForm(p map[string]string, data url.Values) (resp *http.Response, err error) {
+	body := bytes.NewReader([]byte(data.Encode()))
+	return this.Post(p, "application/x-www-form-urlencoded", body, body.Len())
 }
 
-func appendLen(buf []byte, n int) []byte {
-	if n <= 127 {
-		return append(buf, byte(n))
+// PostFile issues a POST to the fcgi responder in multipart(RFC 2046) standard,
+// with form as a string key to a list values (url.Values),
+// and/or with file as a string key to a list file path.
+func (this *FCGIClient) PostFile(p map[string]string, data url.Values, file map[string]string) (resp *http.Response, err error) {
+	buf := &bytes.Buffer{}
+	writer := multipart.NewWriter(buf)
+	bodyType := writer.FormDataContentType()
+
+	for key, val := range data {
+		for _, v0 := range val {
+			err = writer.WriteField(key, v0)
+			if err != nil {
+				return
+			}
+		}
 	}
-	return append(buf, byte((n>>24)|0x80), byte(n>>16), byte(n>>8), byte(n))
+
+	for key, val := range file {
+		fd, e := os.Open(val)
+		if e != nil {
+			return nil, e
+		}
+		defer fd.Close()
+
+		part, e := writer.CreateFormFile(key, filepath.Base(val))
+		if e != nil {
+			return nil, e
+		}
+		_, err = io.Copy(part, fd)
+	}
+
+	err = writer.Close()
+	if err != nil {
+		return
+	}
+
+	return this.Post(p, bodyType, buf, buf.Len())
 }
 
-func parseIntFast(s string) int {
-	n := 0
-	for i := 0; i < len(s); i++ {
-		c := s[i]
-		if c < '0' || c > '9' {
-			return 0
-		}
-		n = n*10 + int(c-'0')
-	}
-	return n
-}
+// Checks whether chunked is part of the encodings stack
+func chunked(te []string) bool { return len(te) > 0 && te[0] == "chunked" }
\ No newline at end of file
--- a/main.go	Mon Mar 09 01:55:11 2026 +0500
+++ b/main.go	Mon Mar 09 02:16:06 2026 +0500
@@ -2,8 +2,11 @@
 
 import (
 	"crypto/tls"
+	"bufio"
 	"fmt"
+	"io"
 	"log"
+	"net/textproto"
 	"net"
 	"net/http"
 	"net/http/httputil"
@@ -16,6 +19,7 @@
 	"strconv"
 	"strings"
 
+	
 	"d2o/fcgi"
 	"d2o/icf"
 )
@@ -160,23 +164,31 @@
 
 func (h *handler) serve(w http.ResponseWriter, r *http.Request, dirs []icf.Directive, _ map[string]string) {
 	var (
-		rootDir  string
-		rootShow bool
-		ndex     []string
-		fcgiAddr string
-		fcgiPat  string
-		rprxAddr string
-		rdirCode int
-		rdirURL  string
+		rootDir   string
+		rootIndex []string // nil = hide; non-nil = show, try these index files first
+		fcgiAddr  string
+		fcgiPat   string
+		rprxAddr  string
 	)
 
 	for _, d := range dirs {
 		switch d.Key {
 		case "root":
 			rootDir = safeArg(d.Args, 0)
-			rootShow = safeArg(d.Args, 1) == "show"
-		case "ndex":
-			ndex = d.Args
+			switch safeArg(d.Args, 1) {
+			case "show":
+				// root /path show [index.php index.html ...]
+				// up to 12 index file candidates; default is index.html
+				if len(d.Args) >= 3 {
+					rootIndex = d.Args[2:]
+				} else {
+					rootIndex = []string{"index.html"}
+				}
+			case "hide", "":
+				rootIndex = nil
+			default:
+				log.Printf("d2o: root: unknown mode %q (want show|hide)", safeArg(d.Args, 1))
+			}
 		case "fcgi":
 			fcgiAddr = safeArg(d.Args, 0)
 			fcgiPat = safeArg(d.Args, 1)
@@ -185,19 +197,10 @@
 			}
 		case "rprx":
 			rprxAddr = safeArg(d.Args, 0)
-		case "rdir":
-			rdirCode, _ = strconv.Atoi(safeArg(d.Args, 0))
-			rdirURL = safeArg(d.Args, 1)
 		}
 	}
 
-	if rdirURL != "" {
-		if rdirCode == 0 {
-			rdirCode = http.StatusFound
-		}
-		http.Redirect(w, r, rdirURL, rdirCode)
-		return
-	}
+	// Priority: rprx > fcgi > static root
 	if rprxAddr != "" {
 		serveReverseProxy(w, r, rprxAddr)
 		return
@@ -210,7 +213,7 @@
 		return
 	}
 	if rootDir != "" {
-		serveStatic(w, r, rootDir, rootShow, ndex)
+		serveStatic(w, r, rootDir, rootIndex)
 		return
 	}
 
@@ -218,7 +221,11 @@
 }
 
 // --- Static -----------------------------------------------------------------
-func serveStatic(w http.ResponseWriter, r *http.Request, rootDir string, show bool, ndex []string) {
+
+// serveStatic serves files from rootDir.
+// rootIndex == nil: directory listing forbidden (hide).
+// rootIndex != nil: try each as index candidate; if none found, show listing.
+func serveStatic(w http.ResponseWriter, r *http.Request, rootDir string, rootIndex []string) {
 	fpath := filepath.Join(rootDir, filepath.FromSlash(path.Clean(r.URL.Path)))
 
 	info, err := os.Stat(fpath)
@@ -232,17 +239,17 @@
 	}
 
 	if info.IsDir() {
-		for _, idx := range ndex {
+		if rootIndex == nil {
+			http.Error(w, "forbidden", http.StatusForbidden)
+			return
+		}
+		for _, idx := range rootIndex {
 			idxPath := filepath.Join(fpath, idx)
 			if _, err := os.Stat(idxPath); err == nil {
 				http.ServeFile(w, r, idxPath)
 				return
 			}
 		}
-		if !show {
-			http.Error(w, "forbidden", http.StatusForbidden)
-			return
-		}
 		listDir(w, r, fpath, r.URL.Path)
 		return
 	}
@@ -295,11 +302,11 @@
 
 func serveFCGI(w http.ResponseWriter, r *http.Request, addr, docRoot string) error {
 	network, address := parseFCGIAddr(addr)
-	conn, err := net.Dial(network, address)
+	client, err := fcgi.Dial(network, address)
 	if err != nil {
 		return fmt.Errorf("connect %s: %w", addr, err)
 	}
-	defer conn.Close()
+	defer client.Close()
 
 	scriptPath := r.URL.Path
 	if docRoot != "" {
@@ -332,7 +339,38 @@
 		params["CONTENT_LENGTH"] = strconv.FormatInt(r.ContentLength, 10)
 	}
 
-	return fcgi.Do(w, r, conn, params)
+	// Use Do() instead of Request() — php-fpm returns CGI response (no HTTP status line),
+	// not a full HTTP response. Request() expects "HTTP/1.1 200 OK" and panics on code 0.
+	cgiReader, err := client.Do(params, r.Body)
+	if err != nil {
+		return fmt.Errorf("fcgi request: %w", err)
+	}
+
+	// Parse CGI headers manually
+	br := bufio.NewReader(cgiReader)
+	tp := textproto.NewReader(br)
+	mime, err := tp.ReadMIMEHeader()
+	if err != nil && len(mime) == 0 {
+		return fmt.Errorf("fcgi response headers: %w", err)
+	}
+
+	status := http.StatusOK
+	if s := mime.Get("Status"); s != "" {
+		code, _, _ := strings.Cut(s, " ")
+		if n, err := strconv.Atoi(code); err == nil && n > 0 {
+			status = n
+		}
+		mime.Del("Status")
+	}
+
+	for k, vs := range mime {
+		for _, v := range vs {
+			w.Header().Add(k, v)
+		}
+	}
+	w.WriteHeader(status)
+	io.Copy(w, br)
+	return nil
 }
 
 func parseFCGIAddr(addr string) (network, address string) {