package cgi import ( "bytes" "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "io/fs" "net" "os" "os/exec" "strings" "tildegit.org/tjp/gus" "tildegit.org/tjp/gus/gemini" ) // CGIDirectory runs any executable files relative to a root directory on the file system. // // It will also find and run any executables _part way_ through the path, so for example // a request for /foo/bar/baz can also run an executable found at /foo or /foo/bar. In // such a case the PATH_INFO environment variable will include the remaining portion of // the URI path. func CGIDirectory(pathRoot, fsRoot string) gus.Handler { fsRoot = strings.TrimRight(fsRoot, "/") return func(ctx context.Context, req *gus.Request) *gus.Response { if !strings.HasPrefix(req.Path, pathRoot) { return nil } path := req.Path[len(pathRoot):] segments := strings.Split(strings.TrimLeft(path, "/"), "/") for i := range append(segments, "") { path := strings.Join(append([]string{fsRoot}, 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:], "/") } return RunCGI(ctx, req, path, pathInfo) } if !isDir { break } } return nil } } 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 } // RunCGI runs a specific program as a CGI script. func RunCGI( ctx context.Context, req *gus.Request, executable string, pathInfo string, ) *gus.Response { pathSegments := strings.Split(executable, "/") dirPath := "." if len(pathSegments) > 1 { dirPath = strings.Join(pathSegments[:len(pathSegments)-1], "/") } basename := pathSegments[len(pathSegments)-1] infoLen := len(pathInfo) if pathInfo == "/" { infoLen -= 1 } scriptName := req.Path[:len(req.Path)-infoLen] scriptName = strings.TrimSuffix(scriptName, "/") cmd := exec.CommandContext(ctx, "./"+basename) cmd.Env = prepareCGIEnv(ctx, req, scriptName, 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) { errMsg := fmt.Sprintf("CGI returned exit code %d", exErr.ExitCode()) return gemini.CGIError(errMsg) } return gemini.Failure(err) } response, err := gemini.ParseResponse(responseBuffer) if err != nil { return gemini.Failure(err) } return response } func prepareCGIEnv( ctx context.Context, req *gus.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="+req.Server.Hostname(), "SERVER_PORT="+req.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), "TLS_CLIENT_CERT="+hex.EncodeToString(cert.Raw), "TLS_CLIENT_ISSUER="+cert.Issuer.String(), "TLS_CLIENT_ISSUER_CN="+cert.Issuer.CommonName, "TLS_CLIENT_SUBJECT="+cert.Subject.String(), "TLS_CLIENT_SUBJECT_CN="+cert.Subject.CommonName, ) } return environ } func fingerprint(raw []byte) string { hash := sha256.Sum256(raw) return hex.EncodeToString(hash[:]) }