changeset 1:3e7247db5c6e

show index.html
author Atarwn Gard <a@qwa.su>
date Mon, 09 Mar 2026 01:04:16 +0500
parents 48bdab3eec8a
children d19133be91ba
files README.md main.go
diffstat 2 files changed, 296 insertions(+), 15 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/README.md	Mon Mar 09 01:04:16 2026 +0500
@@ -0,0 +1,273 @@
+# d2o
+
+Минималистичный веб-сервер на Go с конфигурацией в формате ICF.
+
+## Структура проекта
+
+```
+d2o/
+  go.mod
+  cmd/d2o/
+    main.go       — точка входа, HTTP-обработчик, сборка листеров
+  icf/
+    icf.go        — парсер формата ICF (переиспользуемая библиотека)
+  fcgi/
+    fcgi.go       — минимальный FastCGI-клиент
+```
+
+## Сборка и запуск
+
+```sh
+go build -o d2o ./cmd/d2o/
+./d2o                     # читает /etc/d2obase
+./d2o /path/to/config     # альтернативный путь
+```
+
+---
+
+## Формат конфигурации ICF
+
+**ICF (Inherited Configuration Format)** — текстовый формат правил вида «паттерн → директивы».  
+Вдохновлён синтаксисом bash и базами данных CoreDNS.
+
+### Основные правила синтаксиса
+
+```
+; Это комментарий
+
+; Переменная (без пробелов вокруг =)
+KEY=value
+
+; Абстрактный блок (миксин)
+@name
+|> directive arg1 arg2
+
+; Конкретный блок с наследованием миксина
+block.id @mixin
+|> directive arg1 arg2
+
+; Блок с именованным capture-группой
+<sub>.example.com
+|> root /srv/$sub
+```
+
+### Переменные
+
+Объявляются как `KEY=value` на уровне файла. Подставляются через `$KEY` в аргументах директив.
+
+```
+WWW=/srv/www
+CERT=/etc/acme/example.com
+
+example.com
+|> root $WWW/root
+|> tls $CERT.{crt,key}
+```
+
+### Brace expansion
+
+Аргумент `prefix.{a,b,c}` раскрывается в три отдельных аргумента: `prefix.a`, `prefix.b`, `prefix.c`.  
+Порядок важен — используется для `tls`, где первый аргумент сертификат, второй ключ.
+
+```
+|> tls /etc/acme/example.com.{crt,key}
+; эквивалентно:
+|> tls /etc/acme/example.com.crt /etc/acme/example.com.key
+```
+
+### Capture-группы
+
+В идентификаторе блока `<name>` захватывает любую подстроку до следующего литерала.  
+`<_>` — анонимный wildcard, ничего не сохраняет.
+
+```
+<sub>.example.com
+|> root /srv/$sub        ; $sub подставляется из захваченного значения
+
+<_>.static.example.com
+|> root /srv/static      ; совпадает с чем угодно, capture не нужен
+```
+
+Capture жадный слева: `<sub>.example.com` на запрос `a.b.example.com` даст `sub=a.b`.
+
+### Кавычки в аргументах
+
+Аргументы с пробелами берутся в двойные кавычки:
+
+```
+|> fcgi unix:/run/php-fpm.sock "*.php"
+```
+
+### Абстрактные блоки и наследование
+
+`@name` задаёт набор директив без привязки к паттерну.  
+Конкретный блок наследует их через `block.id @name` — директивы миксина идут первыми, директивы блока их перекрывают.
+
+```
+@base
+|> port 80
+|> port+tls 443
+
+example.com @base
+|> root /srv/www          ; наследует port 80 и port+tls 443
+
+other.com @base
+|> root /srv/other        ; то же самое
+```
+
+### Матчинг блоков
+
+- Домен матчится точно (с учётом capture-групп).
+- Путь матчится как префикс: блок `example.com/api` сработает на `/api/users`.
+- При нескольких совпадениях выигрывает наиболее специфичный (больше совпавших литеральных символов).
+- Сначала проверяется `host/path`, потом `host`.
+
+```
+example.com
+|> root /srv/www
+
+example.com/api
+|> rprx 127.0.0.1:8080   ; перекрывает блок выше для /api/*
+```
+
+---
+
+## Конфигурация d2o
+
+Файл по умолчанию: `/etc/d2obase`
+
+### @d2o — настройки сервера
+
+```
+@d2o
+|> threads 512    ; GOMAXPROCS
+```
+
+| Директива  | Аргументы | Описание |
+|------------|-----------|----------|
+| `threads`  | N         | Количество потоков ОС (GOMAXPROCS) |
+
+### port — HTTP-листенер
+
+```
+|> port 80
+```
+
+| # | Тип   | Описание     |
+|---|-------|--------------|
+| 1 | `int` | Номер порта  |
+
+### port+tls — HTTPS-листенер
+
+```
+|> port+tls 443
+; сертификат берётся из директивы tls того же блока
+```
+
+Знак `+` в имени директивы — обычный символ, не оператор.
+
+| # | Тип    | Описание                                      |
+|---|--------|-----------------------------------------------|
+| 1 | `int`  | Номер порта                                   |
+| 2 | `path` | Путь к сертификату (необязателен, если есть `tls`) |
+| 3 | `path` | Путь к ключу (необязателен, если есть `tls`)  |
+
+### tls — пути к сертификату и ключу
+
+```
+|> tls /etc/acme/example.com.{crt,key}
+```
+
+Используется как источник сертификата для `port+tls` в том же блоке, если пути не указаны явно.
+
+| # | Тип    | Описание          |
+|---|--------|-------------------|
+| 1 | `path` | Путь к сертификату |
+| 2 | `path` | Путь к ключу       |
+
+### root — отдача статики
+
+```
+|> root /srv/www/example.com
+|> root /srv/www/example.com hide       ; листинг запрещён (по умолчанию)
+|> root /srv/www/example.com show       ; листинг разрешён, index-файл index.html
+|> root /srv/www/example.com show index.php index.html   ; свои index-файлы
+```
+
+| # | Тип    | Описание                        |
+|---|--------|---------------------------------|
+| 1 | `path` | Корневая директория             |
+| 2 | `show\|hide` | Режим директорий (по умолчанию `hide`) |
+| 3–14 | `filename` | Index-файлы (только с `show`), проверяются по порядку |
+
+При `show`: сначала ищутся index-файлы по списку, если ни один не найден — отдаётся листинг директории.  
+При `hide` или без аргумента: запрос к директории возвращает 403.
+
+### fcgi — FastCGI
+
+```
+|> fcgi unix:/run/php-fpm.sock
+|> fcgi unix:/run/php-fpm.sock "*.php"   ; только .php файлы
+|> fcgi 127.0.0.1:9000 "*.php"
+```
+
+| # | Тип     | Описание                                           |
+|---|---------|----------------------------------------------------|
+| 1 | `addr`  | Адрес сокета: `unix:/path` или `host:port`         |
+| 2 | `glob`  | Паттерн файлов (по умолчанию `*`, все запросы)     |
+
+Если glob не совпадает, запрос падает в `root` (если задан).
+
+### rprx — обратный прокси
+
+```
+|> rprx 127.0.0.1:3000
+|> rprx http://127.0.0.1:3000
+```
+
+| # | Тип   | Описание                        |
+|---|-------|---------------------------------|
+| 1 | `url` | Адрес backend (схема необязательна, по умолчанию `http://`) |
+
+### Приоритет директив
+
+При одновременном наличии нескольких директив порядок обработки: **rprx > fcgi > root**.
+
+---
+
+## Пример полного конфига
+
+```
+; /etc/d2obase
+
+ACME=/etc/acme/qwaderton.org
+WWW=/srv/www/qwaderton.org
+
+@d2o
+|> threads 512
+
+@ports
+|> tls $ACME.{crt,key}
+|> port 80
+|> port+tls 443
+
+qwaderton.org @ports
+|> root $WWW/root show index.php index.html
+
+qwaderton.org/webfeather @ports
+|> root $WWW/root/webfeather
+|> fcgi unix:/run/php-fpm.sock *.php
+
+<sub>.qwaderton.org @ports
+|> root $WWW/$sub
+```
+
+---
+
+## Известные ограничения
+
+- **Один миксин на блок.** Множественное наследование не поддерживается.
+- **Brace expansion без вложенности.** `{a,{b,c}}` не работает.
+- **FastCGI — один запрос на соединение.** Keep-alive с FPM не реализован.
+- **Нет HTTP→HTTPS редиректа** из коробки — нужно реализовывать отдельным блоком на порту 80.
+- **Нет hot reload** конфига — требуется перезапуск процесса.
\ No newline at end of file
--- a/main.go	Mon Mar 09 00:37:49 2026 +0500
+++ b/main.go	Mon Mar 09 01:04:16 2026 +0500
@@ -36,7 +36,6 @@
 		log.Fatalf("d2o: config error: %v", err)
 	}
 
-	// Apply @d2o global settings
 	for _, d := range cfg.Abstract("d2o") {
 		switch d.Key {
 		case "threads":
@@ -94,7 +93,6 @@
 	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
@@ -102,7 +100,6 @@
 	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" {
@@ -149,7 +146,6 @@
 	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)
@@ -164,11 +160,11 @@
 
 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
+		rootDir   string
+		rootIndex []string
+		fcgiAddr  string
+		fcgiPat   string
+		rprxAddr  string
 	)
 
 	for _, d := range dirs {
@@ -177,9 +173,13 @@
 			rootDir = safeArg(d.Args, 0)
 			switch safeArg(d.Args, 1) {
 			case "show":
-				rootShow = true
+				if len(d.Args) >= 3 {
+					rootIndex = d.Args[2:]
+				} else {
+					rootIndex = []string{"index.html"}
+				}
 			case "hide", "":
-				rootShow = false
+				rootIndex = nil
 			default:
 				log.Printf("d2o: root: unknown mode %q (want show|hide)", safeArg(d.Args, 1))
 			}
@@ -194,7 +194,6 @@
 		}
 	}
 
-	// Priority: rprx > fcgi > static root
 	if rprxAddr != "" {
 		serveReverseProxy(w, r, rprxAddr)
 		return
@@ -207,7 +206,7 @@
 		return
 	}
 	if rootDir != "" {
-		serveStatic(w, r, rootDir, rootShow)
+		serveStatic(w, r, rootDir, rootIndex)
 		return
 	}
 
@@ -216,7 +215,7 @@
 
 // --- Static -----------------------------------------------------------------
 
-func serveStatic(w http.ResponseWriter, r *http.Request, rootDir string, showDir bool) {
+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)
@@ -228,14 +227,23 @@
 		http.Error(w, "internal error", http.StatusInternalServerError)
 		return
 	}
+
 	if info.IsDir() {
-		if !showDir {
+		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
+			}
+		}
 		listDir(w, r, fpath, r.URL.Path)
 		return
 	}
+
 	http.ServeFile(w, r, fpath)
 }