view main.go @ 10:560d4103e12e default tip

Added tag 1.1 for changeset ec97184ea63d
author Atarwn Gard <a@qwa.su>
date Tue, 17 Mar 2026 22:27:30 +0500
parents ec97184ea63d
children
line wrap: on
line source

package main

import (
	"bufio"
	"crypto/tls"
	"fmt"
	"io"
	"log"
	"net"
	"net/http"
	"net/http/httputil"
	"net/textproto"
	"net/url"
	"os"
	"path"
	"path/filepath"
	"regexp"
	"runtime"
	"strconv"
	"strings"
	"time"

	"d2o/fcgi"
	"d2o/icf"
)

type loggingMode uint8

const (
	logNone loggingMode = iota
	logAccess
	logVerbose
)

var (
	curLoggingMode loggingMode = logVerbose
	accessLogger               = log.New(os.Stderr, "", log.LstdFlags)
)

func setLoggingMode(m loggingMode) {
	curLoggingMode = m
	switch m {
	case logNone:
		log.SetOutput(io.Discard)
	case logAccess:
		log.SetOutput(io.Discard)
	case logVerbose:
		// keep standard logger output
	}
}

func accessEnabled() bool  { return curLoggingMode == logAccess || curLoggingMode == logVerbose }
func verboseEnabled() bool { return curLoggingMode == logVerbose }

func accessPrintf(format string, args ...any) {
	if accessEnabled() {
		accessLogger.Printf(format, args...)
	}
}

func verbosePrintf(format string, args ...any) {
	if verboseEnabled() {
		log.Printf(format, args...)
	}
}

func main() {
	cfgPath := "/etc/d2obase"
	if len(os.Args) > 1 {
		cfgPath = os.Args[1]
	}

	f, err := os.Open(cfgPath)
	if err != nil {
		log.Fatalf("d2o: cannot open config: %v", err)
	}
	cfg, err := icf.Parse(f)
	f.Close()
	if err != nil {
		log.Fatalf("d2o: config error: %v", err)
	}

	for _, d := range cfg.Abstract("d2o") {
		switch d.Key {
		case "logging":
			switch strings.ToLower(safeArg(d.Args, 0)) {
			case "", "verbose":
				setLoggingMode(logVerbose)
			case "none":
				setLoggingMode(logNone)
			case "access":
				setLoggingMode(logAccess)
			default:
				log.Fatalf("d2o: unknown logging mode %q (expected none|access|verbose)", safeArg(d.Args, 0))
			}
		case "threads":
			n, err := strconv.Atoi(safeArg(d.Args, 0))
			if err == nil && n > 0 {
				runtime.GOMAXPROCS(n)
				verbosePrintf("d2o: GOMAXPROCS = %d", n)
			}
		}
	}

	ports := collectPorts(cfg)
	if len(ports) == 0 {
		log.Fatal("d2o: no port directives found in config")
	}

	h := &handler{cfg: cfg}
	errCh := make(chan error, len(ports))

	for _, pc := range ports {
		go func(pc portConfig) {
			errCh <- pc.listen(h)
		}(pc)
	}

	log.Fatal(<-errCh)
}

// --- Port collection --------------------------------------------------------

type portConfig struct {
	addr     string
	certFile string
	keyFile  string
	isTLS    bool
}

func (pc portConfig) listen(h http.Handler) error {
	if !pc.isTLS {
		verbosePrintf("d2o: listening on %s (http)", pc.addr)
		return http.ListenAndServe(pc.addr, h)
	}
	cert, err := tls.LoadX509KeyPair(pc.certFile, pc.keyFile)
	if err != nil {
		return fmt.Errorf("d2o: tls: %w", err)
	}
	ln, err := tls.Listen("tcp", pc.addr, &tls.Config{
		Certificates: []tls.Certificate{cert},
		MinVersion:   tls.VersionTLS12,
	})
	if err != nil {
		return fmt.Errorf("d2o: listen %s: %w", pc.addr, err)
	}
	verbosePrintf("d2o: listening on %s (https)", pc.addr)
	return http.Serve(ln, h)
}

func collectPorts(cfg *icf.Config) []portConfig {
	seen := make(map[string]bool)
	var out []portConfig

	for _, b := range cfg.Blocks {
		dirs := cfg.ResolveBlock(b, nil)

		var cert, key string
		for _, d := range dirs {
			if d.Key == "tls" {
				cert = safeArg(d.Args, 0)
				key = safeArg(d.Args, 1)
			}
		}

		for _, d := range dirs {
			switch d.Key {
			case "port":
				addr := ":" + safeArg(d.Args, 0)
				if !seen[addr] {
					seen[addr] = true
					out = append(out, portConfig{addr: addr})
				}
			case "port+tls":
				addr := ":" + safeArg(d.Args, 0)
				c := safeArg(d.Args, 1)
				k := safeArg(d.Args, 2)
				if c == "" {
					c = cert
				}
				if k == "" {
					k = key
				}
				if !seen[addr] {
					seen[addr] = true
					out = append(out, portConfig{addr: addr, certFile: c, keyFile: k, isTLS: true})
				}
			}
		}
	}
	return out
}

// --- HTTP Handler -----------------------------------------------------------

type handler struct {
	cfg *icf.Config
}

func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	start := time.Now()
	rr := &respRecorder{ResponseWriter: w, status: 0}

	host := stripPort(r.Host)
	reqPath := path.Clean(r.URL.Path)

	dirs, caps := h.cfg.Match(host + reqPath)
	if dirs == nil {
		dirs, caps = h.cfg.Match(host)
	}
	if dirs == nil {
		http.Error(rr, "not found", http.StatusNotFound)
		rr.ensureStatus(http.StatusNotFound)
		accessPrintf("d2o: %s %s%s -> %d %dB (%s)", r.Method, r.Host, r.URL.RequestURI(), rr.status, rr.bytes, time.Since(start).Truncate(time.Millisecond))
		return
	}

	h.serve(rr, r, dirs, caps)
	rr.ensureStatus(http.StatusOK)
	accessPrintf("d2o: %s %s%s -> %d %dB (%s)", r.Method, r.Host, r.URL.RequestURI(), rr.status, rr.bytes, time.Since(start).Truncate(time.Millisecond))
}

func (h *handler) serve(w http.ResponseWriter, r *http.Request, dirs []icf.Directive, caps map[string]string) {
	var (
		rootDir  string
		rootShow bool
		ndex     []string
		fcgiAddr string
		fcgiPat  string
		rprxAddr string
		rdirCode int
		rdirURL  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
		case "fcgi":
			fcgiAddr = safeArg(d.Args, 0)
			fcgiPat = safeArg(d.Args, 1)
			if fcgiPat == "" {
				fcgiPat = "*"
			}
		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
		}
		verbosePrintf("d2o: rdir %d -> %s", rdirCode, rdirURL)
		http.Redirect(w, r, rdirURL, rdirCode)
		return
	}
	if rprxAddr != "" {
		verbosePrintf("d2o: rprx -> %s", rprxAddr)
		serveReverseProxy(w, r, rprxAddr)
		return
	}
	if fcgiAddr != "" && matchGlob(fcgiPat, r.URL.Path) {
		verbosePrintf("d2o: fcgi -> %s (%s)", fcgiAddr, r.URL.Path)
		if err := serveFCGI(w, r, fcgiAddr, rootDir); err != nil {
			verbosePrintf("d2o: fcgi error: %v", err)
			http.Error(w, "gateway error", http.StatusBadGateway)
		}
		return
	}
	if rootDir != "" {
		fsPath := path.Clean(r.URL.Path)
		displayPath := fsPath
		if user, ok := caps["user"]; ok && user != "" {
			mount := "/~" + user
			if fsPath == mount || strings.HasPrefix(fsPath, mount+"/") {
				trimmed := strings.TrimPrefix(fsPath, mount)
				if trimmed == "" {
					trimmed = "/"
				}
				fsPath = trimmed
			}
		}

		verbosePrintf("d2o: static -> %s (%s)", rootDir, r.URL.Path)
		serveStatic(w, r, rootDir, rootShow, ndex, fcgiAddr, fcgiPat, fsPath, displayPath)
		return
	}

	http.Error(w, "not found", http.StatusNotFound)
}

// --- Static -----------------------------------------------------------------

// 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, show bool, ndex []string, fcgiAddr, fcgiPat string, fsPath, displayPath string) {
	fpath := filepath.Join(rootDir, filepath.FromSlash(path.Clean(fsPath)))

	info, err := os.Stat(fpath)
	if os.IsNotExist(err) {
		http.Error(w, "not found", http.StatusNotFound)
		return
	}
	if err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}

	if info.IsDir() {
		for _, idx := range ndex {
			idxPath := filepath.Join(fpath, idx)
			if _, err := os.Stat(idxPath); err == nil {
				if fcgiAddr != "" && matchGlob(fcgiPat, idx) {
					r2 := r.Clone(r.Context())
					r2.URL.Path = path.Join(r.URL.Path, idx)
					if err := serveFCGI(w, r2, fcgiAddr, rootDir); err != nil {
						log.Printf("d2o: fcgi error: %v", err)
						http.Error(w, "gateway error", http.StatusBadGateway)
					}
					return
				}
				http.ServeFile(w, r, idxPath)
				return
			}
		}
		if !show {
			http.Error(w, "forbidden", http.StatusForbidden)
			return
		}
		listDir(w, r, fpath, displayPath)
		return
	}

	http.ServeFile(w, r, fpath)
}

func listDir(w http.ResponseWriter, r *http.Request, dir, urlPath string) {
	entries, err := os.ReadDir(dir)
	if err != nil {
		http.Error(w, "cannot read directory", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	fmt.Fprintf(w, "<html><head><title>Index of %s</title><style>body{font-family:monospace}</style></head><body>\n", urlPath)
	fmt.Fprintf(w, "<h2>Index of %s</h2><pre>\n", urlPath)
	if urlPath != "/" {
		fmt.Fprintf(w, "<a href=\"..\">..</a>\n")
	}
	for _, e := range entries {
		name := e.Name()
		if e.IsDir() {
			name += "/"
		}
		fmt.Fprintf(w, "<a href=\"%s\">%s</a>\n", path.Join(urlPath, name), name)
	}
	fmt.Fprintf(w, "</pre><i>d2o webserver</i></body></html>")
}

// --- Reverse proxy ----------------------------------------------------------

func serveReverseProxy(w http.ResponseWriter, r *http.Request, target string) {
	if !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") {
		target = "http://" + target
	}
	u, err := url.Parse(target)
	if err != nil {
		http.Error(w, "bad gateway config", http.StatusInternalServerError)
		return
	}
	proxy := httputil.NewSingleHostReverseProxy(u)
	if verboseEnabled() {
		origDirector := proxy.Director
		proxy.Director = func(req *http.Request) {
			origDirector(req)
			log.Printf("d2o: rprx upstream request: %s %s", req.Method, req.URL.String())
		}
		proxy.ModifyResponse = func(resp *http.Response) error {
			log.Printf("d2o: rprx upstream response: %d %s", resp.StatusCode, resp.Status)
			return nil
		}
	}
	proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
		verbosePrintf("d2o: rprx error: %v", err)
		http.Error(w, "bad gateway", http.StatusBadGateway)
	}
	proxy.ServeHTTP(w, r)
}

// --- FastCGI ----------------------------------------------------------------

func serveFCGI(w http.ResponseWriter, r *http.Request, addr, docRoot string) error {
	network, address := parseFCGIAddr(addr)
	client, err := fcgi.Dial(network, address)
	if err != nil {
		return fmt.Errorf("connect %s: %w", addr, err)
	}
	defer client.Close()

	scriptPath := r.URL.Path
	if docRoot != "" {
		scriptPath = filepath.Join(docRoot, filepath.FromSlash(r.URL.Path))
	}

	params := map[string]string{
		"REQUEST_METHOD":    r.Method,
		"SCRIPT_FILENAME":   scriptPath,
		"SCRIPT_NAME":       r.URL.Path,
		"REQUEST_URI":       r.URL.RequestURI(),
		"QUERY_STRING":      r.URL.RawQuery,
		"SERVER_PROTOCOL":   r.Proto,
		"SERVER_NAME":       stripPort(r.Host),
		"DOCUMENT_ROOT":     docRoot,
		"GATEWAY_INTERFACE": "CGI/1.1",
		"SERVER_SOFTWARE":   "d2o/1.1",
	}
	if r.TLS != nil {
		params["HTTPS"] = "on"
	}
	for k, vs := range r.Header {
		key := "HTTP_" + strings.ToUpper(strings.ReplaceAll(k, "-", "_"))
		params[key] = strings.Join(vs, ", ")
	}
	if ct := r.Header.Get("Content-Type"); ct != "" {
		params["CONTENT_TYPE"] = ct
	}
	if r.ContentLength >= 0 {
		params["CONTENT_LENGTH"] = strconv.FormatInt(r.ContentLength, 10)
	}

	// 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) {
	if strings.HasPrefix(addr, "unix:") {
		return "unix", strings.TrimPrefix(addr, "unix:")
	}
	return "tcp", addr
}

// --- Helpers ----------------------------------------------------------------

func stripPort(host string) string {
	if h, _, err := net.SplitHostPort(host); err == nil {
		return h
	}
	return host
}

func safeArg(args []string, i int) string {
	if i < len(args) {
		return args[i]
	}
	return ""
}

func matchGlob(pattern, s string) bool {
	if pattern == "*" {
		return true
	}
	regPat := "^" + strings.ReplaceAll(regexp.QuoteMeta(pattern), regexp.QuoteMeta("*"), ".*") + "$"
	matched, err := regexp.MatchString(regPat, s)
	return err == nil && matched
}

type respRecorder struct {
	http.ResponseWriter
	status int
	bytes  int64
}

func (rr *respRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
	h, ok := rr.ResponseWriter.(http.Hijacker)
	if !ok {
		return nil, nil, fmt.Errorf("hijack not supported")
	}
	return h.Hijack()
}

func (rr *respRecorder) Flush() {
	if f, ok := rr.ResponseWriter.(http.Flusher); ok {
		f.Flush()
	}
}

func (rr *respRecorder) Push(target string, opts *http.PushOptions) error {
	if p, ok := rr.ResponseWriter.(http.Pusher); ok {
		return p.Push(target, opts)
	}
	return http.ErrNotSupported
}

func (rr *respRecorder) ReadFrom(src io.Reader) (int64, error) {
	// Preserve io.Copy optimizations and count bytes.
	rf, ok := rr.ResponseWriter.(io.ReaderFrom)
	if !ok {
		return io.Copy(rr, src)
	}
	if rr.status == 0 {
		rr.status = http.StatusOK
	}
	n, err := rf.ReadFrom(src)
	rr.bytes += n
	return n, err
}

func (rr *respRecorder) WriteHeader(code int) {
	rr.status = code
	rr.ResponseWriter.WriteHeader(code)
}

func (rr *respRecorder) Write(p []byte) (int, error) {
	if rr.status == 0 {
		rr.status = http.StatusOK
	}
	n, err := rr.ResponseWriter.Write(p)
	rr.bytes += int64(n)
	return n, err
}

func (rr *respRecorder) ensureStatus(defaultCode int) {
	if rr.status == 0 {
		rr.status = defaultCode
	}
}