diff --git a/go.mod b/go.mod
index 5192329..8061f2e 100644
--- a/go.mod
+++ b/go.mod
@@ -5,7 +5,6 @@ go 1.25.4
require (
github.com/fatih/color v1.18.0
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a
- github.com/pelletier/go-toml/v2 v2.2.4
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/yuin/gopher-lua v1.1.1
)
@@ -13,5 +12,5 @@ require (
require (
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
- golang.org/x/sys v0.25.0 // indirect
+ golang.org/x/sys v0.32.0 // indirect
)
diff --git a/go.sum b/go.sum
index d6a1d7b..9b7a9cc 100644
--- a/go.sum
+++ b/go.sum
@@ -7,8 +7,6 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
-github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
@@ -16,5 +14,5 @@ github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
-golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
+golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
diff --git a/lib/fes.lua b/lib/fes.lua
index d6a476b..968c037 100644
--- a/lib/fes.lua
+++ b/lib/fes.lua
@@ -121,9 +121,9 @@ function M.fes(proto, header, footer)
if proto == "http" and site_config.favicon then
site_config.favicon =
- ''
+ ''
end
local default_header
@@ -167,12 +167,20 @@ end
function M:build()
if self.proto == "http" then
- local header = self.default_header:gsub("{{TITLE}}", self.title or "Document")
- local favicon_html = self.favicon or ''
+ local header = self.header:gsub("{{TITLE}}", self.title or "Document")
+ local favicon_html = self.favicon
+ or
+ ''
header = header:gsub("{{FAVICON}}", favicon_html)
- local footer = self.default_footer:gsub("{{COPYRIGHT}}", self.copyright or symbol.legal.copyright .. "The Copyright Holder")
- return header .. table.concat(self.parts, "\n") .. default_footer
+
+ local footer = self.footer:gsub(
+ "{{COPYRIGHT}}",
+ self.copyright or symbol.legal.copyright .. "The Copyright Holder"
+ )
+
+ return header .. table.concat(self.parts, "\n") .. footer
end
+
return table.concat(self.parts, "\n")
end
diff --git a/main.go b/main.go
index ce47324..68e5cb7 100644
--- a/main.go
+++ b/main.go
@@ -31,6 +31,7 @@ func init() {
config.Lib = lib
config.Doc = documentation
config.Verbose = flag.Bool("verbose", false, "Enable verbose logging")
+ config.Proto = flag.String("proto", "", "Force protocol")
}
func main() {
@@ -93,10 +94,10 @@ func main() {
os.Exit(1)
}
case "run":
+ ui.Log("Fes is starting")
if *config.Port == 3000 {
ui.WARNING("Using default port, this may lead to conflicts with other services")
}
- ui.Log("Fes is starting")
ui.Log("Fes version=%s, commit=%s, just started", version.VERSION, version.GetCommit())
runtime.ReadMemStats(&m)
diff --git a/modules/config/config.go b/modules/config/config.go
index 0691c60..f43d16d 100644
--- a/modules/config/config.go
+++ b/modules/config/config.go
@@ -12,6 +12,7 @@ var Color *bool
var Static *bool
var Docker *bool
var Verbose *bool
+var Proto *string
type AppConfig struct {
App struct {
diff --git a/modules/gemini/gemini.go b/modules/gemini/gemini.go
new file mode 100644
index 0000000..9f8c7b8
--- /dev/null
+++ b/modules/gemini/gemini.go
@@ -0,0 +1,196 @@
+package gemini
+
+import (
+ "bufio"
+ "crypto/tls"
+ "net"
+ "net/url"
+ "strings"
+ "sync"
+)
+
+const (
+ StatusSuccess = 20
+ StatusTemporaryFail = 40
+ StatusPermanentFail = 50
+ StatusNotFound = 51
+ StatusBadRequest = 59
+)
+
+type Handler interface {
+ ServeGemini(ResponseWriter, *Request)
+}
+
+type HandlerFunc func(ResponseWriter, *Request)
+
+func (f HandlerFunc) ServeGemini(w ResponseWriter, r *Request) {
+ f(w, r)
+}
+
+type ServeMux struct {
+ mu sync.RWMutex
+ handlers map[string]Handler
+}
+
+func NewServeMux() *ServeMux {
+ return &ServeMux{
+ handlers: make(map[string]Handler),
+ }
+}
+
+func (m *ServeMux) Handle(pattern string, h Handler) {
+ m.mu.Lock()
+ m.handlers[pattern] = h
+ m.mu.Unlock()
+}
+
+func (m *ServeMux) HandleFunc(pattern string, f func(ResponseWriter, *Request)) {
+ m.Handle(pattern, HandlerFunc(f))
+}
+
+func (m *ServeMux) ServeGemini(w ResponseWriter, r *Request) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+
+ path := r.URL.Path
+
+ for pattern, handler := range m.handlers {
+ if strings.HasPrefix(path, pattern) {
+ handler.ServeGemini(w, r)
+ return
+ }
+ }
+
+ w.WriteHeader(StatusNotFound, "not found")
+}
+
+var DefaultServeMux = NewServeMux()
+
+func Handle(pattern string, h Handler) {
+ DefaultServeMux.Handle(pattern, h)
+}
+
+func HandleFunc(pattern string, f func(ResponseWriter, *Request)) {
+ DefaultServeMux.HandleFunc(pattern, f)
+}
+
+type Request struct {
+ URL *url.URL
+ Conn net.Conn
+}
+
+type ResponseWriter interface {
+ Write([]byte) (int, error)
+ WriteHeader(status int, meta string)
+}
+
+type response struct {
+ conn net.Conn
+ wroteHeader bool
+}
+
+func (r *response) WriteHeader(status int, meta string) {
+ if r.wroteHeader {
+ return
+ }
+ r.conn.Write([]byte(
+ strings.TrimSpace(
+ strings.Join([]string{
+ strings.TrimSpace(
+ strings.Join([]string{
+ string(rune(status/10 + '0')),
+ string(rune(status%10 + '0')),
+ }, ""),
+ ),
+ meta,
+ }, " "),
+ ) + "\r\n"))
+ r.wroteHeader = true
+}
+
+func (r *response) Write(b []byte) (int, error) {
+ if !r.wroteHeader {
+ r.WriteHeader(StatusSuccess, "text/gemini")
+ }
+ return r.conn.Write(b)
+}
+
+type Server struct {
+ Addr string
+ Handler Handler
+ TLSConfig *tls.Config
+}
+
+func (s *Server) ListenAndServeTLS(certFile, keyFile string) error {
+ handler := s.Handler
+ if handler == nil {
+ handler = DefaultServeMux
+ }
+
+ cert, err := tls.LoadX509KeyPair(certFile, keyFile)
+ if err != nil {
+ return err
+ }
+
+ cfg := s.TLSConfig
+ if cfg == nil {
+ cfg = &tls.Config{}
+ }
+ cfg.Certificates = []tls.Certificate{cert}
+
+ ln, err := tls.Listen("tcp", s.Addr, cfg)
+ if err != nil {
+ return err
+ }
+
+ for {
+ conn, err := ln.Accept()
+ if err != nil {
+ continue
+ }
+ go s.serve(conn, handler)
+ }
+}
+
+func (s *Server) serve(conn net.Conn, handler Handler) {
+ defer conn.Close()
+
+ reader := bufio.NewReader(conn)
+
+ line, err := reader.ReadString('\n')
+ if err != nil {
+ return
+ }
+
+ if len(line) > 1026 {
+ conn.Write([]byte("59 request too long\r\n"))
+ return
+ }
+
+ line = strings.TrimSpace(line)
+
+ u, err := url.Parse(line)
+ if err != nil {
+ conn.Write([]byte("59 bad request\r\n"))
+ return
+ }
+
+ req := &Request{
+ URL: u,
+ Conn: conn,
+ }
+
+ rw := &response{
+ conn: conn,
+ }
+
+ handler.ServeGemini(rw, req)
+}
+
+func ListenAndServeTLS(addr, certFile, keyFile string, h Handler) error {
+ s := &Server{
+ Addr: addr,
+ Handler: h,
+ }
+ return s.ListenAndServeTLS(certFile, keyFile)
+}
diff --git a/modules/server/dirs.go b/modules/server/dirs.go
index aefa13e..399c464 100644
--- a/modules/server/dirs.go
+++ b/modules/server/dirs.go
@@ -59,18 +59,21 @@ func loadDirs() map[string]string {
routes := make(map[string]string)
if entries, err := os.ReadDir("www"); err == nil {
+ ui.LogVerbose("reading www/")
if err := handleDir(entries, "www", routes, "", false); err != nil {
ui.Warning("failed to handle www directory", err)
}
}
if entries, err := os.ReadDir("static"); err == nil {
+ ui.LogVerbose("reading static/")
if err := handleDir(entries, "static", routes, "/static", true); err != nil {
ui.Warning("failed to handle static directory", err)
}
}
if entries, err := os.ReadDir("archive"); err == nil {
+ ui.LogVerbose("reading archive/")
if err := handleDir(entries, "archive", routes, "/archive", true); err != nil {
ui.Warning("failed to handle archive directory", err)
}
diff --git a/modules/server/gemini.go b/modules/server/gemini.go
new file mode 100644
index 0000000..f668781
--- /dev/null
+++ b/modules/server/gemini.go
@@ -0,0 +1,67 @@
+package server
+
+import (
+ "fes/modules/config"
+ "fes/modules/gemini"
+ "fes/modules/ui"
+ "os"
+ "strings"
+)
+
+func geminiHandler(w gemini.ResponseWriter, r *gemini.Request) {
+ ui.LogVerbose("Received %s", r.URL.Path)
+
+ route, ok := Routes[r.URL.Path]
+
+ var err error = nil
+
+ /* defer won't update paramaters unless we do this. */
+ defer func() {
+ ui.Path(route, err)
+ }()
+
+ if !ok {
+ err = config.ErrRouteMiss
+ route = r.URL.Path
+
+ if strings.HasPrefix(route, "/archive") {
+ w.Write([]byte("# error: not implemented"))
+ // err = readArchive(w, route)
+ } else {
+ w.WriteHeader(gemini.StatusNotFound, "StatusNotFound")
+ w.Write([]byte(`
+
404 Not Found
+
+404 Not Found
+
fes
+
+`))
+ }
+ return
+ }
+
+ params := make(map[string]string)
+ for k, v := range r.URL.Query() {
+ if len(v) > 0 {
+ params[k] = v[0]
+ }
+ }
+
+ var data []byte
+ if strings.HasSuffix(route, ".lua") {
+ data, err = render(route, reqData{path: r.URL.Path, params: params}, &Sets)
+ } else if strings.HasSuffix(route, ".md") {
+ data, err = os.ReadFile(route)
+ data = []byte(markdownToHTML(string(data)))
+ data = []byte("\n" + string(data))
+ } else {
+ ui.LogVerbose("serving unrecognized file")
+ data, err = os.ReadFile(route)
+ }
+
+ if err != nil {
+ w.WriteHeader(-1, err.Error())
+ }
+
+ w.Write(data)
+}
diff --git a/modules/server/http.go b/modules/server/http.go
index 1c93d20..6577e95 100644
--- a/modules/server/http.go
+++ b/modules/server/http.go
@@ -10,6 +10,8 @@ import (
)
func httpHandler(w http.ResponseWriter, r *http.Request) {
+ ui.LogVerbose("Received %s", r.URL.Path)
+
route, ok := Routes[r.URL.Path]
var err error = nil
@@ -53,6 +55,7 @@ func httpHandler(w http.ResponseWriter, r *http.Request) {
data = []byte(markdownToHTML(string(data)))
data = []byte("\n" + string(data))
} else {
+ ui.LogVerbose("serving unrecognized file")
data, err = os.ReadFile(route)
}
diff --git a/modules/server/render.go b/modules/server/render.go
index 3c431ff..f41e205 100644
--- a/modules/server/render.go
+++ b/modules/server/render.go
@@ -1,23 +1,19 @@
package server
import (
- "errors"
"fes/modules/config"
"io/fs"
- "os"
"path/filepath"
"strings"
lua "github.com/yuin/gopher-lua"
)
-/* this is the request data we pass over the bus to the application, via the fes.bus interface */
type reqData struct {
path string
params map[string]string
}
-/* declarative sets in the page declaration */
type DeclarativeSets struct {
protos struct {
http bool
@@ -25,7 +21,6 @@ type DeclarativeSets struct {
}
}
-/* returns a string of rendered markup */
func render(luapath string, requestData reqData, setBuffer *DeclarativeSets) ([]byte, error) {
L := lua.NewState()
defer L.Close()
@@ -36,10 +31,8 @@ func render(luapath string, requestData reqData, setBuffer *DeclarativeSets) ([]
continue
}
path := filepath.Join("lib", de.Name())
- if data, err := config.Lib.ReadFile(path); err != nil {
- continue
- } else {
- L.DoString(string(data))
+ if data, err := config.Lib.ReadFile(path); err == nil {
+ _ = L.DoString(string(data))
}
}
}
@@ -50,10 +43,11 @@ func render(luapath string, requestData reqData, setBuffer *DeclarativeSets) ([]
if err != nil {
panic(err)
}
+
if err := L.DoString(string(fileData)); err != nil {
panic(err)
}
- L.Push(L.Get(-1))
+
return 1
})
}
@@ -63,88 +57,40 @@ func render(luapath string, requestData reqData, setBuffer *DeclarativeSets) ([]
preloadLuaModule("lib.util", "lib/util.lua")
L.PreloadModule("fes", func(L *lua.LState) int {
- mod := L.NewTable()
- libModules := []string{}
- if ents, err := fs.ReadDir(config.Lib, "lib"); err == nil {
- for _, e := range ents {
- if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") {
- continue
- }
- libModules = append(libModules, strings.TrimSuffix(e.Name(), ".lua"))
- }
- }
- for _, modName := range libModules {
- path := filepath.Join("lib", modName+".lua")
- fileData, err := config.Lib.ReadFile(path)
- if err != nil {
- continue
- }
- if err := L.DoString(string(fileData)); err != nil {
- continue
- }
- val := L.Get(-1)
- L.Pop(1)
- tbl, ok := val.(*lua.LTable)
- if !ok || tbl == nil {
- tbl = L.NewTable()
- }
- if modName == "fes" {
- tbl.ForEach(func(k, v lua.LValue) { mod.RawSet(k, v) })
- } else {
- mod.RawSetString(modName, tbl)
- }
+ fileData, err := config.Lib.ReadFile("lib/fes.lua")
+ if err != nil {
+ panic(err)
}
- mod.RawSetString("app", func() *lua.LTable {
- app := L.NewTable()
- includeDir := "include"
+ if err := L.DoString(string(fileData)); err != nil {
+ panic(err)
+ }
- includes, err := os.ReadDir(includeDir)
- if err != nil {
- return app // load no includes
- }
+ mod := L.Get(-1)
+ L.Pop(1)
- for _, de := range includes {
- if de.IsDir() || !strings.HasSuffix(de.Name(), ".lua") {
- continue
- }
-
- base := strings.TrimSuffix(de.Name(), ".lua")
- path := filepath.Join(includeDir, de.Name())
-
- if _, err := os.Stat(path); err != nil {
- continue
- } else if err := L.DoFile(path); err != nil {
- continue
- }
-
- val := L.Get(-1)
- L.Pop(1)
- tbl, ok := val.(*lua.LTable)
- if !ok || tbl == nil {
- tbl = L.NewTable()
- }
- app.RawSetString(base, tbl)
- }
-
- return app
- }())
+ tbl, ok := mod.(*lua.LTable)
+ if !ok {
+ panic("fes module did not return table")
+ }
bus := L.NewTable()
bus.RawSetString("url", lua.LString(requestData.path))
+
params := L.NewTable()
for k, v := range requestData.params {
params.RawSetString(k, lua.LString(v))
}
bus.RawSetString("params", params)
- mod.RawSetString("bus", bus)
- mod.RawSetString("markdown_to_html", L.NewFunction(func(L *lua.LState) int {
+ tbl.RawSetString("bus", bus)
+
+ tbl.RawSetString("markdown_to_html", L.NewFunction(func(L *lua.LState) int {
L.Push(lua.LString(markdownToHTML(L.ToString(1))))
return 1
}))
- L.Push(mod)
+ L.Push(tbl)
return 1
})
@@ -156,48 +102,27 @@ func render(luapath string, requestData reqData, setBuffer *DeclarativeSets) ([]
return []byte(""), nil
}
- L.SetGlobal("__fes_result", L.Get(-1))
- if err := L.DoString("return tostring(__fes_result)"); err != nil {
- L.GetGlobal("__fes_result")
- if s := L.ToString(-1); s != "" {
- return []byte(s), nil
- }
+ val := L.Get(-1)
+ L.Pop(1)
+
+ if val == lua.LNil {
return []byte(""), nil
}
- if s := L.ToString(-1); s != "" {
+ if err := L.CallByParam(lua.P{
+ Fn: L.GetGlobal("tostring"),
+ NRet: 1,
+ Protect: true,
+ }, val); err != nil {
+ return nil, err
+ }
+
+ s := L.ToString(-1)
+ L.Pop(1)
+
+ if s != "" {
return []byte(s), nil
}
- L.GetGlobal("require")
- L.Push(lua.LString("std"))
- L.Call(1, 1)
-
- ret := L.Get(-1)
- L.Pop(1)
-
- tbl, ok := ret.(*lua.LTable)
- if !ok {
- return nil, errors.New("Could not determine protocol")
- }
-
- proto := tbl.RawGetString("proto")
-
- protoTbl, ok := proto.(*lua.LTable)
- if !ok {
- return nil, errors.New("Could not determine protocol")
- }
-
- protoTbl.ForEach(func(key lua.LValue, value lua.LValue) {
- if str, ok := value.(lua.LString); ok {
- switch str.String() {
- case "http":
- setBuffer.protos.http = true
- case "gemini":
- setBuffer.protos.gemini = true
- }
- }
- })
-
return []byte(""), nil
}
diff --git a/modules/server/server.go b/modules/server/server.go
index d9526b0..b98b1e8 100644
--- a/modules/server/server.go
+++ b/modules/server/server.go
@@ -2,12 +2,13 @@ package server
import (
"fes/modules/config"
+ "fes/modules/gemini"
"fes/modules/ui"
"fmt"
- "log"
"net/http"
"os"
"path/filepath"
+ "sync"
)
var Routes map[string]string
@@ -30,10 +31,41 @@ func Start(dir string) {
ui.Log("running root=%s, port=%d.", root, *config.Port)
+ ui.LogVerbose("start loading directories")
Routes = loadDirs()
http.HandleFunc("/", httpHandler)
- ui.Log("Server initialized")
+ gemini.HandleFunc("/", geminiHandler)
- log.Fatal(http.ListenAndServe(fmt.Sprintf("0.0.0.0:%d", *config.Port), nil))
+ var wg sync.WaitGroup
+ errs := make(chan error, 2)
+
+ wg.Add(2)
+
+ go func() {
+ defer wg.Done()
+ errs <- http.ListenAndServe(fmt.Sprintf("0.0.0.0:%d", *config.Port), nil)
+ }()
+
+ go func() {
+ defer wg.Done()
+ errs <- gemini.ListenAndServeTLS(fmt.Sprintf("0.0.0.0:%d", *config.Port-1035), "cert.pem", "key.pem", nil)
+ }()
+
+ ui.Log("Server initialized")
+ wg.Wait()
+ close(errs)
+
+ var collected []error
+
+ for err := range errs {
+ if err != nil {
+ collected = append(collected, err)
+ }
+ }
+
+ if len(collected) > 0 {
+ fmt.Printf("errors: %v\n", collected)
+ return
+ }
}
diff --git a/test/default_gmi/README.md b/test/default_gmi/README.md
new file mode 100644
index 0000000..7e8b977
--- /dev/null
+++ b/test/default_gmi/README.md
@@ -0,0 +1,33 @@
+# default
+
+```
+fes new default
+```
+
+> **Know what you are doing?** Delete this file. Have fun!
+
+## Project Structure
+
+Inside your Fes project, you'll see the following directories and files:
+
+```
+.
+├── Fes.toml
+├── README.md
+└── www
+ └── index.lua
+```
+
+Fes looks for `.lua` files in the `www/` directory. Each file is exposed as a route based on its file name.
+
+## Commands
+
+All commands are run from the root of the project, from a terminal:
+
+| Command | Action |
+| :------------------------ | :----------------------------------------------- |
+| `fes run .` | Runs the project at `.` |
+
+## What to learn more?
+
+Check out [Fes's docs](https://docs.vxserver.dev/static/fes.html).
\ No newline at end of file
diff --git a/test/default_gmi/cert.pem b/test/default_gmi/cert.pem
new file mode 100644
index 0000000..7034838
--- /dev/null
+++ b/test/default_gmi/cert.pem
@@ -0,0 +1,18 @@
+-----BEGIN CERTIFICATE-----
+MIIC8zCCAdugAwIBAgIUWobZlV5Gp72Z4LUD/hjRb2aa+GgwDQYJKoZIhvcNAQEL
+BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDIxNTAzMDgxOVoXDTI3MDIx
+NTAzMDgxOVowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
+AAOCAQ8AMIIBCgKCAQEAxvNs7/1cJ/6kdlo7CAwIUc+d8L5cbqw3KKYMl/9JSnqp
+HutIcl23LrF0ylClnAkTbuuDmzED73Z8788eaoIsjmwNA5yapkmDkjh/y8CRg1+2
+8iEuneHAeKZosHGdfjBcOzLVPo713Mw2m3yXeeVLfn/FLUql3l/Au0xu+oVT4XB/
+aZ//j3spgT4xIFggXMYchs9EW1pJpD4pnKDo+ZBATuAJjDy4OstGKzFiEiNSWfiI
+K8VLM6V74xdEiojcyy2TCHDSYOIozsB5iQRV9PcXyyIEGw7wTx/o9wrkShff+pyn
+seLJ644FnGRvEkZpTWg18NTC18JNLVGqmuSqbwzGZQIDAQABoz0wOzAaBgNVHREE
+EzARgglsb2NhbGhvc3SHBH8AAAEwHQYDVR0OBBYEFI/tgi60jQUeMqILEeZf7m80
+MWB1MA0GCSqGSIb3DQEBCwUAA4IBAQATIwsWSuPBFb/n4q60QgScVIGjTIHTJGUT
+di6ButyVug4zCltsMIw+VwfigRk77eyqZjbdm9Tmn/1cUTxLnNMBNyUPabojmf32
+ItWGCLmI9QBW2/d8oK1rxLiDDQ5FwzWloeavwJC2E3xKy5xmcQicv1iTvpJnLRFJ
+amyrY9dDVo0qAsLnnOmwc0OEnzpcYclegTOD9jUgEMJ00oLrYsWqXC8KvPaWcIu7
+MiCj9j+U9ncU0fWE0WCOZr8VOgjtJeiHN1CLPOWbsSaZRWLBWlgF4AJGI2VXVM7d
+BAb5y4cDIqmDnrTl3DUk8BlCnMdHopvl1ZrZWKgQJbfhvOagUv91
+-----END CERTIFICATE-----
diff --git a/test/default_gmi/key.pem b/test/default_gmi/key.pem
new file mode 100644
index 0000000..f71e8a9
--- /dev/null
+++ b/test/default_gmi/key.pem
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDG82zv/Vwn/qR2
+WjsIDAhRz53wvlxurDcopgyX/0lKeqke60hyXbcusXTKUKWcCRNu64ObMQPvdnzv
+zx5qgiyObA0DnJqmSYOSOH/LwJGDX7byIS6d4cB4pmiwcZ1+MFw7MtU+jvXczDab
+fJd55Ut+f8UtSqXeX8C7TG76hVPhcH9pn/+PeymBPjEgWCBcxhyGz0RbWkmkPimc
+oOj5kEBO4AmMPLg6y0YrMWISI1JZ+IgrxUszpXvjF0SKiNzLLZMIcNJg4ijOwHmJ
+BFX09xfLIgQbDvBPH+j3CuRKF9/6nKex4snrjgWcZG8SRmlNaDXw1MLXwk0tUaqa
+5KpvDMZlAgMBAAECggEAFR1lvOzDWJ1OgB8gb8CzK1ehGBlj/vz5F6/T21flQ+nT
+xCvNcxHeLK75ybUYdoCCFv4Y6CIiHEqThPIS9NPe/bibAvyebzKTK7QiYBIOf4Zr
+iLQb2fbJMiTbLIrKX8erKj9BYZPTpTzpOMRW4UGEKydNWnq3MuwvrNE2YBFBb0YM
+3RcSxs+nEkYs0sbZGtkqy2gYGbr0WcWHi3tNRWTT5FXM5VY84XY8QCPqDTj5fXys
+DwDFQDBJc0l/IYRcaen+4UNliVaJRto/ZZaqhwnPra1d16PLYhWmfAkYKgWCEIhw
++b/+mV+6oUnORGbmq1TiSzZ9U9WqNwSo/8ZoNC8WywKBgQDm7SK2zFyXx4SdnVfL
+XikJCYLdnsLaaP/Z7iCZgt7oaHUDg6Eb/SAqdEB/YPcCHXox6ChUOqHe7ee7ROCk
+1wD3xI+kV4E9yZqs2zeRJrv8W8Q0JjJXdVrXy6vFQ0z/132QNXLJpr7KXGctE9zX
+XheT+yisgJQSd6O7HX1Ow67EKwKBgQDcjX7tThSw+dloyEywbi3dA8VmGVuH3UPk
+3zgD6dEA/xt/OpD3LgDgHOLIL+lbR4LAfxjS8RTHGOON8iVcCAi6k7gAf+0WU2Uh
+6GkA4gUM6mx1zSl7k4/vmJa1WpxG11bCdWPvNt3X0cvYMGNPfhTITqz4F81M6+9p
+ZEmaCGaHrwKBgGGmSzSjbFAeZXzE6TgtJAsXQ4h1tw3msrI0GQLxLVN3wGtxAPK1
+8iEhsZhrp2f0kRSDiHI9rO95CLHO6XOrG1SqgNdMzXEUTFzmAjRV/c4z+97VfBox
+nO19ybALyoaxV/5gK58L7MfjlRmhuZQ0zKGd5lAzuumoP8tDKBbjdoarAoGAcNJ9
+DH21vfaBjcVw3YvvMDE+qITuOqkokwrRB8dzIBRgB4x5HcjNr9d29zrzH7uMGlap
+5zZmD5ceyL0G+XYuqOrp5G+MY7BTeq3+EPKN7NZ6lyRVRR7uMX2YEruAWAjOG/mb
+HoKtpzpuEXBnTQHNNc5xUxQx9Fh5ByvDLuV/NYcCgYEAy/An+fPP6Lkl4nwbcnbP
+npAimzB6z25ftFeNMfggJYOukQomAeuwS5QYdLvqtPdqjtrqRQJrXFW9q0Nmt5HM
+h0WuMCrKDWfYdZbZ8E6y3zqUXb5J66M2mcu+8ED6zUvktOBHXgIS7YnXYf46illx
+3+8QDk1ufotloNSokoM/BTw=
+-----END PRIVATE KEY-----
diff --git a/test/default_gmi/localhost.cnf b/test/default_gmi/localhost.cnf
new file mode 100644
index 0000000..9f88d19
--- /dev/null
+++ b/test/default_gmi/localhost.cnf
@@ -0,0 +1,16 @@
+[req]
+default_bits = 2048
+prompt = no
+default_md = sha256
+x509_extensions = v3_req
+distinguished_name = dn
+
+[dn]
+CN = localhost
+
+[v3_req]
+subjectAltName = @alt_names
+
+[alt_names]
+DNS.1 = localhost
+IP.1 = 127.0.0.1
diff --git a/test/default_gmi/www/index.lua b/test/default_gmi/www/index.lua
new file mode 100644
index 0000000..8d65a16
--- /dev/null
+++ b/test/default_gmi/www/index.lua
@@ -0,0 +1,8 @@
+local fes = require("fes")
+local site = fes.fes("gemini")
+
+-- site.copyright = fes.util.copyright("https://example.com", "vx-clutch")
+
+site:h(1, "Hello, World!")
+
+return site