Mercurial Hosting > d2o
changeset 0:48bdab3eec8a
Initial
| author | Atarwn Gard <a@qwa.su> |
|---|---|
| date | Mon, 09 Mar 2026 00:37:49 +0500 |
| parents | |
| children | 3e7247db5c6e |
| files | fcgi/fcgi.go go.mod icf/icf.go main.go |
| diffstat | 4 files changed, 876 insertions(+), 0 deletions(-) [+] |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/fcgi/fcgi.go Mon Mar 09 00:37:49 2026 +0500 @@ -0,0 +1,195 @@ +// 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. +package fcgi + +import ( + "bufio" + "encoding/binary" + "io" + "net/http" + "net/textproto" + "strings" +) + +// Reference: https://fastcgi-archives.github.io/FastCGI_Specification.html + +const ( + fcgiVersion = 1 + fcgiBeginRequest = 1 + fcgiParams = 4 + fcgiStdin = 5 + fcgiStdout = 6 + fcgiEndRequest = 3 + fcgiRoleResponder = 1 + fcgiRequestID = 1 +) + +// 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) + + if err := writeRecord(bw, fcgiBeginRequest, fcgiRequestID, + []byte{0, fcgiRoleResponder, 0, 0, 0, 0, 0, 0}); err != nil { + return err + } + + if err := writeRecord(bw, fcgiParams, fcgiRequestID, encodeParams(params)); err != nil { + return err + } + if err := writeRecord(bw, fcgiParams, fcgiRequestID, nil); err != nil { + return err + } + + 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 + } + } + } + if err := writeRecord(bw, fcgiStdin, fcgiRequestID, nil); err != nil { + return err + } + if err := bw.Flush(); err != nil { + return err + } + + // Read response + var stdout strings.Builder + br := bufio.NewReader(conn) + + for { + hdr := make([]byte, 8) + if _, err := io.ReadFull(br, hdr); err != nil { + return err + } + recType := hdr[1] + contentLen := int(binary.BigEndian.Uint16(hdr[4:6])) + paddingLen := int(hdr[6]) + + 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 + } + } + + switch recType { + case fcgiStdout: + stdout.Write(content) + case fcgiEndRequest: + return forwardResponse(w, stdout.String()) + } + // fcgiStderr is silently discarded + } +} + +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 + } + + 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") + } + + for k, vs := range mime { + for _, v := range vs { + w.Header().Add(k, v) + } + } + w.WriteHeader(status) + w.Write([]byte(body)) + return nil +} + +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, + } + if _, err := w.Write(hdr); err != nil { + return err + } + if len(content) > 0 { + if _, err := w.Write(content); err != nil { + return err + } + } + if padding > 0 { + if _, err := w.Write(make([]byte, padding)); err != nil { + return err + } + } + return nil +} + +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...) + } + return buf +} + +func appendLen(buf []byte, n int) []byte { + if n <= 127 { + return append(buf, byte(n)) + } + return append(buf, byte((n>>24)|0x80), byte(n>>16), byte(n>>8), byte(n)) +} + +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 +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/go.mod Mon Mar 09 00:37:49 2026 +0500 @@ -0,0 +1,3 @@ +module d2o + +go 1.21
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/icf/icf.go Mon Mar 09 00:37:49 2026 +0500 @@ -0,0 +1,321 @@ +// Package icf implements the Inherited Configuration Format parser. +// +// ICF is a rule-based configuration format with variables, abstract blocks +// (mixins), pattern matching with named capture groups, and brace expansion. +// +// Syntax: +// +// ; comment +// KEY=value variable +// @name abstract block (mixin) +// |> directive arg {a,b} directive with optional brace expansion +// block.id @mixin concrete block inheriting a mixin +// <cap>.example.com block with named capture group +// <_>.example.com anonymous wildcard +package icf + +import ( + "bufio" + "fmt" + "io" + "strings" +) + +// Directive is a single key + arguments line inside a block. +type Directive struct { + Key string + Args []string +} + +// Config holds the parsed and fully resolved state of an ICF file. +type Config struct { + vars map[string]string + abstract map[string][]Directive + Blocks []ParsedBlock +} + +type ParsedBlock struct { + ID string + Mixin string + Directives []Directive +} + +// Parse reads an ICF document and returns a ready Config. +func Parse(r io.Reader) (*Config, error) { + c := &Config{ + vars: make(map[string]string), + abstract: make(map[string][]Directive), + } + + var raw []ParsedBlock + var cur *ParsedBlock + + flush := func() { + if cur != nil { + raw = append(raw, *cur) + cur = nil + } + } + + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := stripComment(strings.TrimSpace(scanner.Text())) + if line == "" { + continue + } + + // Variable assignment: KEY=value + if i := strings.Index(line, "="); i > 0 && !strings.HasPrefix(line, "|>") { + key := line[:i] + if isVarName(key) { + c.vars[key] = strings.TrimSpace(line[i+1:]) + continue + } + } + + // Directive: |> key args... + if strings.HasPrefix(line, "|>") { + if cur == nil { + return nil, fmt.Errorf("icf: directive outside block: %q", line) + } + parts := strings.Fields(strings.TrimSpace(line[2:])) + if len(parts) == 0 { + continue + } + cur.Directives = append(cur.Directives, Directive{ + Key: parts[0], + Args: braceExpand(parts[1:]), + }) + continue + } + + // Block header: id [@mixin] + flush() + parts := strings.Fields(line) + pb := ParsedBlock{ID: parts[0]} + if len(parts) >= 2 && strings.HasPrefix(parts[1], "@") { + pb.Mixin = parts[1][1:] + } + cur = &pb + } + flush() + + if err := scanner.Err(); err != nil { + return nil, err + } + + // Separate abstract from concrete blocks + for _, b := range raw { + if strings.HasPrefix(b.ID, "@") { + c.abstract[b.ID[1:]] = b.Directives + } else { + c.Blocks = append(c.Blocks, b) + } + } + + return c, nil +} + +// Abstract returns the directives of a named abstract block with variables +// substituted. Returns nil if not found. +func (c *Config) Abstract(name string) []Directive { + dirs, ok := c.abstract[name] + if !ok { + return nil + } + return c.applyVars(dirs, nil) +} + +// Match finds the most specific block matching input (e.g. "host/path") and +// returns resolved directives plus named captures. +// Domain part is matched exactly (with captures); path part uses prefix match. +func (c *Config) Match(input string) ([]Directive, map[string]string) { + inHost, inPath, _ := strings.Cut(input, "/") + + type hit struct { + block ParsedBlock + captures map[string]string + score int + } + var best *hit + + for _, b := range c.Blocks { + patHost, patPath, hasPath := strings.Cut(b.ID, "/") + + caps := make(map[string]string) + + domScore, ok := matchExact(patHost, inHost, caps) + if !ok { + continue + } + + pathScore := 0 + if hasPath { + pathScore, ok = matchPrefix(patPath, inPath, caps) + if !ok { + continue + } + } + + score := domScore*1000 + pathScore + if best == nil || score > best.score { + best = &hit{block: b, captures: caps, score: score} + } + } + + if best == nil { + return nil, nil + } + + return c.ResolveBlock(best.block, best.captures), best.captures +} + +// ResolveBlock merges mixin then block directives, substituting vars+captures. +func (c *Config) ResolveBlock(b ParsedBlock, caps map[string]string) []Directive { + var merged []Directive + if b.Mixin != "" { + merged = append(merged, c.abstract[b.Mixin]...) + } + merged = append(merged, b.Directives...) + return c.applyVars(merged, caps) +} + +func (c *Config) applyVars(dirs []Directive, caps map[string]string) []Directive { + out := make([]Directive, len(dirs)) + for i, d := range dirs { + out[i].Key = c.subst(d.Key, caps) + out[i].Args = make([]string, len(d.Args)) + for j, a := range d.Args { + out[i].Args[j] = c.subst(a, caps) + } + } + return out +} + +func (c *Config) subst(s string, caps map[string]string) string { + if !strings.Contains(s, "$") { + return s + } + var b strings.Builder + i := 0 + for i < len(s) { + if s[i] != '$' { + b.WriteByte(s[i]) + i++ + continue + } + j := i + 1 + for j < len(s) && isVarChar(s[j]) { + j++ + } + name := s[i+1 : j] + if v, ok := caps[name]; ok { + b.WriteString(v) + } else if v, ok := c.vars[name]; ok { + b.WriteString(v) + } else { + b.WriteString(s[i:j]) + } + i = j + } + return b.String() +} + +func matchExact(pat, s string, caps map[string]string) (int, bool) { + score, rem, ok := matchCaptures(pat, s, caps) + if !ok || rem != "" { + return 0, false + } + return score, true +} + +func matchPrefix(pat, s string, caps map[string]string) (int, bool) { + score, _, ok := matchCaptures(pat, s, caps) + return score, ok +} + +func matchCaptures(pat, inp string, caps map[string]string) (int, string, bool) { + for { + if pat == "" { + return 0, inp, true + } + + if strings.HasPrefix(pat, "<") { + end := strings.Index(pat, ">") + if end == -1 { + return 0, "", false + } + capName := pat[1:end] + rest := pat[end+1:] + + for split := 1; split <= len(inp); split++ { + candidate := inp[:split] + remaining := inp[split:] + score, finalRem, ok := matchCaptures(rest, remaining, caps) + if ok { + if capName != "_" { + caps[capName] = candidate + } + return score, finalRem, true + } + } + return 0, "", false + } + + if inp == "" || pat[0] != inp[0] { + return 0, "", false + } + score, rem, ok := matchCaptures(pat[1:], inp[1:], caps) + return score + 1, rem, ok + } +} + +func braceExpand(args []string) []string { + var out []string + for _, a := range args { + out = append(out, expandOne(a)...) + } + return out +} + +func expandOne(s string) []string { + start := strings.Index(s, "{") + end := strings.Index(s, "}") + if start == -1 || end == -1 || end < start { + return []string{s} + } + prefix := s[:start] + suffix := s[end+1:] + var out []string + for _, v := range strings.Split(s[start+1:end], ",") { + out = append(out, prefix+strings.TrimSpace(v)+suffix) + } + return out +} + +func stripComment(line string) string { + if strings.HasPrefix(line, ";") { + return "" + } + if i := strings.Index(line, " ;"); i != -1 { + return strings.TrimSpace(line[:i]) + } + return line +} + +func isVarName(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + if !isVarChar(s[i]) { + return false + } + } + return true +} + +func isVarChar(c byte) bool { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '_' +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/main.go Mon Mar 09 00:37:49 2026 +0500 @@ -0,0 +1,357 @@ +package main + +import ( + "crypto/tls" + "fmt" + "log" + "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) + } + + // Apply @d2o global settings + 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) +} + +// collectPorts scans all blocks for port / port+tls directives. +func collectPorts(cfg *icf.Config) []portConfig { + seen := make(map[string]bool) + var out []portConfig + + for _, b := range cfg.Blocks { + dirs := cfg.ResolveBlock(b, nil) + + // Collect tls paths defined in this block + 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) + + // Try host+path first, then host alone + 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 + fcgiAddr string + fcgiPat string + rprxAddr string + ) + + for _, d := range dirs { + switch d.Key { + case "root": + rootDir = safeArg(d.Args, 0) + switch safeArg(d.Args, 1) { + case "show": + rootShow = true + case "hide", "": + rootShow = false + 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) + if fcgiPat == "" { + fcgiPat = "*" + } + case "rprx": + rprxAddr = safeArg(d.Args, 0) + } + } + + // Priority: rprx > fcgi > static root + 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) + return + } + + http.Error(w, "not found", http.StatusNotFound) +} + +// --- Static ----------------------------------------------------------------- + +func serveStatic(w http.ResponseWriter, r *http.Request, rootDir string, showDir bool) { + 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() { + if !showDir { + 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></head><body>\n", urlPath) + fmt.Fprintf(w, "<h2>Index of %s</h2><hr><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><hr><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) + conn, err := net.Dial(network, address) + if err != nil { + return fmt.Errorf("connect %s: %w", addr, err) + } + defer conn.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) + } + + return fcgi.Do(w, r, conn, params) +} + +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 +}
