view main.go @ 7:8e4813b4e509

update index style + justfile additions
author Atarwn Gard <a@qwa.su>
date Tue, 10 Mar 2026 12:12:33 +0500
parents 07b6f06899e0
children 2ffb8028ccbb
line wrap: on
line source

package main

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

	
	"d2o/fcgi"
	"d2o/icf"
)

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 "threads":
			n, err := strconv.Atoi(safeArg(d.Args, 0))
			if err == nil && n > 0 {
				runtime.GOMAXPROCS(n)
				log.Printf("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 {
		log.Printf("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)
	}
	log.Printf("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) {
	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(w, "not found", http.StatusNotFound)
		return
	}

	h.serve(w, r, dirs, caps)
}

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
	)

	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
		}
		http.Redirect(w, r, rdirURL, rdirCode)
		return
	}
	if rprxAddr != "" {
		serveReverseProxy(w, r, rprxAddr)
		return
	}
	if fcgiAddr != "" && matchGlob(fcgiPat, r.URL.Path) {
		if err := serveFCGI(w, r, fcgiAddr, rootDir); err != nil {
			log.Printf("d2o: fcgi error: %v", err)
			http.Error(w, "gateway error", http.StatusBadGateway)
		}
		return
	}
	if rootDir != "" {
		serveStatic(w, r, rootDir, rootShow, ndex, fcgiAddr, fcgiPat)
		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) {
	fpath := filepath.Join(rootDir, filepath.FromSlash(path.Clean(r.URL.Path)))

	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, r.URL.Path)
		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)
	proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
		log.Printf("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.0",
	}
	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
}