Mercurial Hosting > d2o
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) {
