Mercurial Hosting > d2o
annotate 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 |
| rev | line source |
|---|---|
| 0 | 1 package main |
| 2 | |
| 3 import ( | |
| 4 "crypto/tls" | |
| 3 | 5 "bufio" |
| 0 | 6 "fmt" |
| 3 | 7 "io" |
| 0 | 8 "log" |
| 3 | 9 "net/textproto" |
| 0 | 10 "net" |
| 11 "net/http" | |
| 12 "net/http/httputil" | |
| 13 "net/url" | |
| 14 "os" | |
| 15 "path" | |
| 16 "path/filepath" | |
| 17 "regexp" | |
| 18 "runtime" | |
| 19 "strconv" | |
| 20 "strings" | |
| 21 | |
| 3 | 22 |
| 0 | 23 "d2o/fcgi" |
| 24 "d2o/icf" | |
| 25 ) | |
| 26 | |
| 27 func main() { | |
| 28 cfgPath := "/etc/d2obase" | |
| 29 if len(os.Args) > 1 { | |
| 30 cfgPath = os.Args[1] | |
| 31 } | |
| 32 | |
| 33 f, err := os.Open(cfgPath) | |
| 34 if err != nil { | |
| 35 log.Fatalf("d2o: cannot open config: %v", err) | |
| 36 } | |
| 37 cfg, err := icf.Parse(f) | |
| 38 f.Close() | |
| 39 if err != nil { | |
| 40 log.Fatalf("d2o: config error: %v", err) | |
| 41 } | |
| 42 | |
| 43 for _, d := range cfg.Abstract("d2o") { | |
| 44 switch d.Key { | |
| 45 case "threads": | |
| 46 n, err := strconv.Atoi(safeArg(d.Args, 0)) | |
| 47 if err == nil && n > 0 { | |
| 48 runtime.GOMAXPROCS(n) | |
| 49 log.Printf("d2o: GOMAXPROCS = %d", n) | |
| 50 } | |
| 51 } | |
| 52 } | |
| 53 | |
| 54 ports := collectPorts(cfg) | |
| 55 if len(ports) == 0 { | |
| 56 log.Fatal("d2o: no port directives found in config") | |
| 57 } | |
| 58 | |
| 59 h := &handler{cfg: cfg} | |
| 60 errCh := make(chan error, len(ports)) | |
| 61 | |
| 62 for _, pc := range ports { | |
| 63 go func(pc portConfig) { | |
| 64 errCh <- pc.listen(h) | |
| 65 }(pc) | |
| 66 } | |
| 67 | |
| 68 log.Fatal(<-errCh) | |
| 69 } | |
| 70 | |
| 71 // --- Port collection -------------------------------------------------------- | |
| 72 | |
| 73 type portConfig struct { | |
| 74 addr string | |
| 75 certFile string | |
| 76 keyFile string | |
| 77 isTLS bool | |
| 78 } | |
| 79 | |
| 80 func (pc portConfig) listen(h http.Handler) error { | |
| 81 if !pc.isTLS { | |
| 82 log.Printf("d2o: listening on %s (http)", pc.addr) | |
| 83 return http.ListenAndServe(pc.addr, h) | |
| 84 } | |
| 85 cert, err := tls.LoadX509KeyPair(pc.certFile, pc.keyFile) | |
| 86 if err != nil { | |
| 87 return fmt.Errorf("d2o: tls: %w", err) | |
| 88 } | |
| 89 ln, err := tls.Listen("tcp", pc.addr, &tls.Config{ | |
| 90 Certificates: []tls.Certificate{cert}, | |
| 91 MinVersion: tls.VersionTLS12, | |
| 92 }) | |
| 93 if err != nil { | |
| 94 return fmt.Errorf("d2o: listen %s: %w", pc.addr, err) | |
| 95 } | |
| 96 log.Printf("d2o: listening on %s (https)", pc.addr) | |
| 97 return http.Serve(ln, h) | |
| 98 } | |
| 99 | |
| 100 func collectPorts(cfg *icf.Config) []portConfig { | |
| 101 seen := make(map[string]bool) | |
| 102 var out []portConfig | |
| 103 | |
| 104 for _, b := range cfg.Blocks { | |
| 105 dirs := cfg.ResolveBlock(b, nil) | |
| 106 | |
| 107 var cert, key string | |
| 108 for _, d := range dirs { | |
| 109 if d.Key == "tls" { | |
| 110 cert = safeArg(d.Args, 0) | |
| 111 key = safeArg(d.Args, 1) | |
| 112 } | |
| 113 } | |
| 114 | |
| 115 for _, d := range dirs { | |
| 116 switch d.Key { | |
| 117 case "port": | |
| 118 addr := ":" + safeArg(d.Args, 0) | |
| 119 if !seen[addr] { | |
| 120 seen[addr] = true | |
| 121 out = append(out, portConfig{addr: addr}) | |
| 122 } | |
| 123 case "port+tls": | |
| 124 addr := ":" + safeArg(d.Args, 0) | |
| 125 c := safeArg(d.Args, 1) | |
| 126 k := safeArg(d.Args, 2) | |
| 127 if c == "" { | |
| 128 c = cert | |
| 129 } | |
| 130 if k == "" { | |
| 131 k = key | |
| 132 } | |
| 133 if !seen[addr] { | |
| 134 seen[addr] = true | |
| 135 out = append(out, portConfig{addr: addr, certFile: c, keyFile: k, isTLS: true}) | |
| 136 } | |
| 137 } | |
| 138 } | |
| 139 } | |
| 140 return out | |
| 141 } | |
| 142 | |
| 143 // --- HTTP Handler ----------------------------------------------------------- | |
| 144 | |
| 145 type handler struct { | |
| 146 cfg *icf.Config | |
| 147 } | |
| 148 | |
| 149 func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| 150 host := stripPort(r.Host) | |
| 151 reqPath := path.Clean(r.URL.Path) | |
| 152 | |
| 153 dirs, caps := h.cfg.Match(host + reqPath) | |
| 154 if dirs == nil { | |
| 155 dirs, caps = h.cfg.Match(host) | |
| 156 } | |
| 157 if dirs == nil { | |
| 158 http.Error(w, "not found", http.StatusNotFound) | |
| 159 return | |
| 160 } | |
| 161 | |
| 162 h.serve(w, r, dirs, caps) | |
| 163 } | |
| 164 | |
| 165 func (h *handler) serve(w http.ResponseWriter, r *http.Request, dirs []icf.Directive, _ map[string]string) { | |
| 166 var ( | |
|
5
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
167 rootDir string |
|
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
168 rootShow bool |
|
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
169 ndex []string |
|
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
170 fcgiAddr string |
|
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
171 fcgiPat string |
|
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
172 rprxAddr string |
|
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
173 rdirCode int |
|
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
174 rdirURL string |
| 0 | 175 ) |
| 176 | |
| 177 for _, d := range dirs { | |
| 178 switch d.Key { | |
| 179 case "root": | |
| 180 rootDir = safeArg(d.Args, 0) | |
|
5
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
181 rootShow = safeArg(d.Args, 1) == "show" |
|
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
182 case "ndex": |
|
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
183 ndex = d.Args |
| 0 | 184 case "fcgi": |
| 185 fcgiAddr = safeArg(d.Args, 0) | |
| 186 fcgiPat = safeArg(d.Args, 1) | |
| 187 if fcgiPat == "" { | |
| 188 fcgiPat = "*" | |
| 189 } | |
| 190 case "rprx": | |
| 191 rprxAddr = safeArg(d.Args, 0) | |
|
5
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
192 case "rdir": |
|
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
193 rdirCode, _ = strconv.Atoi(safeArg(d.Args, 0)) |
|
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
194 rdirURL = safeArg(d.Args, 1) |
| 0 | 195 } |
| 196 } | |
| 197 | |
|
5
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
198 if rdirURL != "" { |
|
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
199 if rdirCode == 0 { |
|
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
200 rdirCode = http.StatusFound |
|
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
201 } |
|
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
202 http.Redirect(w, r, rdirURL, rdirCode) |
|
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
203 return |
|
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
204 } |
| 0 | 205 if rprxAddr != "" { |
| 206 serveReverseProxy(w, r, rprxAddr) | |
| 207 return | |
| 208 } | |
| 209 if fcgiAddr != "" && matchGlob(fcgiPat, r.URL.Path) { | |
| 210 if err := serveFCGI(w, r, fcgiAddr, rootDir); err != nil { | |
| 211 log.Printf("d2o: fcgi error: %v", err) | |
| 212 http.Error(w, "gateway error", http.StatusBadGateway) | |
| 213 } | |
| 214 return | |
| 215 } | |
| 216 if rootDir != "" { | |
|
5
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
217 serveStatic(w, r, rootDir, rootShow, ndex, fcgiAddr, fcgiPat) |
| 0 | 218 return |
| 219 } | |
| 220 | |
| 221 http.Error(w, "not found", http.StatusNotFound) | |
| 222 } | |
| 223 | |
| 224 // --- Static ----------------------------------------------------------------- | |
| 3 | 225 |
| 226 // serveStatic serves files from rootDir. | |
| 227 // rootIndex == nil: directory listing forbidden (hide). | |
| 228 // rootIndex != nil: try each as index candidate; if none found, show listing. | |
|
5
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
229 func serveStatic(w http.ResponseWriter, r *http.Request, rootDir string, show bool, ndex []string, fcgiAddr, fcgiPat string) { |
| 0 | 230 fpath := filepath.Join(rootDir, filepath.FromSlash(path.Clean(r.URL.Path))) |
| 231 | |
| 232 info, err := os.Stat(fpath) | |
| 233 if os.IsNotExist(err) { | |
| 234 http.Error(w, "not found", http.StatusNotFound) | |
| 235 return | |
| 236 } | |
| 237 if err != nil { | |
| 238 http.Error(w, "internal error", http.StatusInternalServerError) | |
| 239 return | |
| 240 } | |
| 1 | 241 |
| 0 | 242 if info.IsDir() { |
|
5
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
243 for _, idx := range ndex { |
| 1 | 244 idxPath := filepath.Join(fpath, idx) |
| 245 if _, err := os.Stat(idxPath); err == nil { | |
|
5
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
246 if fcgiAddr != "" && matchGlob(fcgiPat, idx) { |
|
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
247 r2 := r.Clone(r.Context()) |
|
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
248 r2.URL.Path = path.Join(r.URL.Path, idx) |
|
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
249 if err := serveFCGI(w, r2, fcgiAddr, rootDir); err != nil { |
|
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
250 log.Printf("d2o: fcgi error: %v", err) |
|
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
251 http.Error(w, "gateway error", http.StatusBadGateway) |
|
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
252 } |
|
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
253 return |
|
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
254 } |
| 1 | 255 http.ServeFile(w, r, idxPath) |
| 256 return | |
| 257 } | |
| 258 } | |
|
5
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
259 if !show { |
|
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
260 http.Error(w, "forbidden", http.StatusForbidden) |
|
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
261 return |
|
07b6f06899e0
my tired ass deleted a fix that was partially working
Atarwn Gard <a@qwa.su>
parents:
3
diff
changeset
|
262 } |
| 0 | 263 listDir(w, r, fpath, r.URL.Path) |
| 264 return | |
| 265 } | |
| 1 | 266 |
| 0 | 267 http.ServeFile(w, r, fpath) |
| 268 } | |
| 269 | |
| 270 func listDir(w http.ResponseWriter, r *http.Request, dir, urlPath string) { | |
| 271 entries, err := os.ReadDir(dir) | |
| 272 if err != nil { | |
| 273 http.Error(w, "cannot read directory", http.StatusInternalServerError) | |
| 274 return | |
| 275 } | |
| 276 w.Header().Set("Content-Type", "text/html; charset=utf-8") | |
|
7
8e4813b4e509
update index style + justfile additions
Atarwn Gard <a@qwa.su>
parents:
5
diff
changeset
|
277 fmt.Fprintf(w, "<html><head><title>Index of %s</title><style>body{font-family:monospace}</style></head><body>\n", urlPath) |
|
8e4813b4e509
update index style + justfile additions
Atarwn Gard <a@qwa.su>
parents:
5
diff
changeset
|
278 fmt.Fprintf(w, "<h2>Index of %s</h2><pre>\n", urlPath) |
| 0 | 279 if urlPath != "/" { |
| 280 fmt.Fprintf(w, "<a href=\"..\">..</a>\n") | |
| 281 } | |
| 282 for _, e := range entries { | |
| 283 name := e.Name() | |
| 284 if e.IsDir() { | |
| 285 name += "/" | |
| 286 } | |
| 287 fmt.Fprintf(w, "<a href=\"%s\">%s</a>\n", path.Join(urlPath, name), name) | |
| 288 } | |
|
7
8e4813b4e509
update index style + justfile additions
Atarwn Gard <a@qwa.su>
parents:
5
diff
changeset
|
289 fmt.Fprintf(w, "</pre><i>d2o webserver</i></body></html>") |
| 0 | 290 } |
| 291 | |
| 292 // --- Reverse proxy ---------------------------------------------------------- | |
| 293 | |
| 294 func serveReverseProxy(w http.ResponseWriter, r *http.Request, target string) { | |
| 295 if !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") { | |
| 296 target = "http://" + target | |
| 297 } | |
| 298 u, err := url.Parse(target) | |
| 299 if err != nil { | |
| 300 http.Error(w, "bad gateway config", http.StatusInternalServerError) | |
| 301 return | |
| 302 } | |
| 303 proxy := httputil.NewSingleHostReverseProxy(u) | |
| 304 proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { | |
| 305 log.Printf("d2o: rprx error: %v", err) | |
| 306 http.Error(w, "bad gateway", http.StatusBadGateway) | |
| 307 } | |
| 308 proxy.ServeHTTP(w, r) | |
| 309 } | |
| 310 | |
| 311 // --- FastCGI ---------------------------------------------------------------- | |
| 312 | |
| 313 func serveFCGI(w http.ResponseWriter, r *http.Request, addr, docRoot string) error { | |
| 314 network, address := parseFCGIAddr(addr) | |
| 3 | 315 client, err := fcgi.Dial(network, address) |
| 0 | 316 if err != nil { |
| 317 return fmt.Errorf("connect %s: %w", addr, err) | |
| 318 } | |
| 3 | 319 defer client.Close() |
| 0 | 320 |
| 321 scriptPath := r.URL.Path | |
| 322 if docRoot != "" { | |
| 323 scriptPath = filepath.Join(docRoot, filepath.FromSlash(r.URL.Path)) | |
| 324 } | |
| 325 | |
| 326 params := map[string]string{ | |
| 327 "REQUEST_METHOD": r.Method, | |
| 328 "SCRIPT_FILENAME": scriptPath, | |
| 329 "SCRIPT_NAME": r.URL.Path, | |
| 330 "REQUEST_URI": r.URL.RequestURI(), | |
| 331 "QUERY_STRING": r.URL.RawQuery, | |
| 332 "SERVER_PROTOCOL": r.Proto, | |
| 333 "SERVER_NAME": stripPort(r.Host), | |
| 334 "DOCUMENT_ROOT": docRoot, | |
| 335 "GATEWAY_INTERFACE": "CGI/1.1", | |
| 336 "SERVER_SOFTWARE": "d2o/1.0", | |
| 337 } | |
| 338 if r.TLS != nil { | |
| 339 params["HTTPS"] = "on" | |
| 340 } | |
| 341 for k, vs := range r.Header { | |
| 342 key := "HTTP_" + strings.ToUpper(strings.ReplaceAll(k, "-", "_")) | |
| 343 params[key] = strings.Join(vs, ", ") | |
| 344 } | |
| 345 if ct := r.Header.Get("Content-Type"); ct != "" { | |
| 346 params["CONTENT_TYPE"] = ct | |
| 347 } | |
| 348 if r.ContentLength >= 0 { | |
| 349 params["CONTENT_LENGTH"] = strconv.FormatInt(r.ContentLength, 10) | |
| 350 } | |
| 351 | |
| 3 | 352 // Use Do() instead of Request() — php-fpm returns CGI response (no HTTP status line), |
| 353 // not a full HTTP response. Request() expects "HTTP/1.1 200 OK" and panics on code 0. | |
| 354 cgiReader, err := client.Do(params, r.Body) | |
| 355 if err != nil { | |
| 356 return fmt.Errorf("fcgi request: %w", err) | |
| 357 } | |
| 358 | |
| 359 // Parse CGI headers manually | |
| 360 br := bufio.NewReader(cgiReader) | |
| 361 tp := textproto.NewReader(br) | |
| 362 mime, err := tp.ReadMIMEHeader() | |
| 363 if err != nil && len(mime) == 0 { | |
| 364 return fmt.Errorf("fcgi response headers: %w", err) | |
| 365 } | |
| 366 | |
| 367 status := http.StatusOK | |
| 368 if s := mime.Get("Status"); s != "" { | |
| 369 code, _, _ := strings.Cut(s, " ") | |
| 370 if n, err := strconv.Atoi(code); err == nil && n > 0 { | |
| 371 status = n | |
| 372 } | |
| 373 mime.Del("Status") | |
| 374 } | |
| 375 | |
| 376 for k, vs := range mime { | |
| 377 for _, v := range vs { | |
| 378 w.Header().Add(k, v) | |
| 379 } | |
| 380 } | |
| 381 w.WriteHeader(status) | |
| 382 io.Copy(w, br) | |
| 383 return nil | |
| 0 | 384 } |
| 385 | |
| 386 func parseFCGIAddr(addr string) (network, address string) { | |
| 387 if strings.HasPrefix(addr, "unix:") { | |
| 388 return "unix", strings.TrimPrefix(addr, "unix:") | |
| 389 } | |
| 390 return "tcp", addr | |
| 391 } | |
| 392 | |
| 393 // --- Helpers ---------------------------------------------------------------- | |
| 394 | |
| 395 func stripPort(host string) string { | |
| 396 if h, _, err := net.SplitHostPort(host); err == nil { | |
| 397 return h | |
| 398 } | |
| 399 return host | |
| 400 } | |
| 401 | |
| 402 func safeArg(args []string, i int) string { | |
| 403 if i < len(args) { | |
| 404 return args[i] | |
| 405 } | |
| 406 return "" | |
| 407 } | |
| 408 | |
| 409 func matchGlob(pattern, s string) bool { | |
| 410 if pattern == "*" { | |
| 411 return true | |
| 412 } | |
| 413 regPat := "^" + strings.ReplaceAll(regexp.QuoteMeta(pattern), regexp.QuoteMeta("*"), ".*") + "$" | |
| 414 matched, err := regexp.MatchString(regPat, s) | |
| 415 return err == nil && matched | |
| 2 | 416 } |
