This repository has been archived on 2023-05-01. You can view files and clone it, but cannot push or open issues or pull requests.
gus/contrib/cgi/cgi.go

194 lines
4.3 KiB
Go

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]
scriptName := req.Path[:len(req.Path)-len(pathInfo)]
if strings.HasSuffix(scriptName, "/") {
scriptName = scriptName[:len(scriptName)-1]
}
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_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[:])
}