package cgi import ( "bytes" "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "io/fs" "net" "os" "os/exec" "strings" "tildegit.org/tjp/gus/gemini" ) func CGIHandler(pathPrefix, rootDir string) gemini.Handler { rootDir = strings.TrimRight(rootDir, "/") return func(ctx context.Context, req *gemini.Request) *gemini.Response { if !strings.HasPrefix(req.Path, pathPrefix) { return gemini.NotFound("Resource does not exist.") } path := req.Path[len(pathPrefix):] segments := strings.Split(strings.TrimLeft(path, "/"), "/") for i := range append(segments, "") { path := strings.Join(append([]string{rootDir}, segments[:i]...), "/") path = strings.TrimRight(path, "/") isDir, isExecutable, err := executableFile(path) if err != nil { return gemini.Failure(err) } if isExecutable { pathInfo := "" if len(segments) > i+1 { pathInfo = strings.Join(segments[i+1:], "/") } return runCGI(ctx, req.Server, req, path, pathInfo) } if !isDir { break } } return gemini.NotFound("Resource does not exist.") } } func executableFile(path string) (bool, bool, error) { file, err := os.Open(path) if isNotExistError(err) { return false, false, nil } if err != nil { return false, false, err } defer file.Close() info, err := file.Stat() if err != nil { return false, false, err } if info.IsDir() { return true, false, nil } // readable + executable by anyone return false, info.Mode()&0005 == 0005, nil } func isNotExistError(err error) bool { if err != nil { var pathErr *fs.PathError if errors.As(err, &pathErr) { e := pathErr.Err if errors.Is(e, fs.ErrInvalid) || errors.Is(e, fs.ErrNotExist) { return true } } } return false } func runCGI( ctx context.Context, server *gemini.Server, req *gemini.Request, filePath string, pathInfo string, ) *gemini.Response { pathSegments := strings.Split(filePath, "/") dirPath := "." if len(pathSegments) > 1 { dirPath = strings.Join(pathSegments[:len(pathSegments)-1], "/") } filePath = "./" + pathSegments[len(pathSegments)-1] cmd := exec.CommandContext(ctx, filePath) cmd.Env = prepareCGIEnv(ctx, server, req, filePath, pathInfo) cmd.Dir = dirPath responseBuffer := &bytes.Buffer{} cmd.Stdout = responseBuffer if err := cmd.Run(); err != nil { var exErr *exec.ExitError if errors.As(err, &exErr) { return gemini.CGIError(fmt.Sprintf("CGI returned with exit code %d", exErr.ExitCode())) } return gemini.Failure(err) } response, err := gemini.ParseResponse(responseBuffer) if err != nil { return gemini.Failure(err) } return response } func prepareCGIEnv( ctx context.Context, server *gemini.Server, req *gemini.Request, scriptName string, pathInfo string, ) []string { var authType string if len(req.TLSState.PeerCertificates) > 0 { authType = "Certificate" } environ := []string{ "AUTH_TYPE=" + authType, "CONTENT_LENGTH=", "CONTENT_TYPE=", "GATEWAY_INTERFACE=CGI/1.1", "PATH_INFO=" + pathInfo, "PATH_TRANSLATED=", "QUERY_STRING=" + req.RawQuery, } host, _, _ := net.SplitHostPort(req.RemoteAddr.String()) environ = append(environ, "REMOTE_ADDR="+host) environ = append( environ, "REMOTE_HOST=", "REMOTE_IDENT=", "SCRIPT_NAME="+scriptName, "SERVER_NAME="+server.Hostname(), "SERVER_PORT="+server.Port(), "SERVER_PROTOCOL=GEMINI", "SERVER_SOFTWARE=GUS", ) if len(req.TLSState.PeerCertificates) > 0 { cert := req.TLSState.PeerCertificates[0] environ = append( environ, "TLS_CLIENT_HASH="+fingerprint(cert.Raw), ) } return environ } func fingerprint(raw []byte) string { hash := sha256.Sum256(raw) return hex.EncodeToString(hash[:]) }