From 96f3a7607ffbdb349a4c2eff35efdf11b8d35a4e Mon Sep 17 00:00:00 2001 From: tjpcc Date: Tue, 10 Jan 2023 13:46:35 -0700 Subject: Add a CGI contrib --- TODO.md | 3 +- contrib/cgi/cgi.go | 178 ++++++++++++++++++++++++++++++++++++++++++++ examples/cgi/cgi-bin/cowsay | 16 ++++ examples/cgi/main.go | 49 ++++++++++++ examples/cowsay/main.go | 13 ++-- examples/fileserver/main.go | 13 ++-- examples/inspectls/main.go | 15 ++-- gemini/request.go | 5 +- gemini/response.go | 35 +++++++++ gemini/serve.go | 52 +++++++++++-- 10 files changed, 343 insertions(+), 36 deletions(-) create mode 100644 contrib/cgi/cgi.go create mode 100755 examples/cgi/cgi-bin/cowsay create mode 100644 examples/cgi/main.go diff --git a/TODO.md b/TODO.md index 94f963a..63e748a 100644 --- a/TODO.md +++ b/TODO.md @@ -5,9 +5,8 @@ - [x] serving files - [x] directory index files - [x] directory listing - - [ ] reject symlinks pointing outside fs root - [ ] filtering middleware - [x] contrib - request logging -- [ ] contrib - CGI +- [x] contrib - CGI - [ ] contrib - shared hosting helper - [ ] contrib - TLS auth diff --git a/contrib/cgi/cgi.go b/contrib/cgi/cgi.go new file mode 100644 index 0000000..2e20485 --- /dev/null +++ b/contrib/cgi/cgi.go @@ -0,0 +1,178 @@ +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[:]) +} diff --git a/examples/cgi/cgi-bin/cowsay b/examples/cgi/cgi-bin/cowsay new file mode 100755 index 0000000..e63e909 --- /dev/null +++ b/examples/cgi/cgi-bin/cowsay @@ -0,0 +1,16 @@ +#!/usr/bin/env sh + +set -euo pipefail + +if [ -z "$QUERY_STRING" ]; +then + printf "10 Enter a phrase.\r\n" + exit 0 +fi + +decodeURL() { printf "%b\n" "$(sed 's/+/ /g; s/%\([0-9a-fA-F][0-9a-fA-F]\)/\\x\1/g;')"; } + +printf "20 text/gemini\r\n\`\`\`\n" +echo $QUERY_STRING | decodeURL | cowsay +echo "\`\`\`" +echo "\n=> $SCRIPT_NAME again" diff --git a/examples/cgi/main.go b/examples/cgi/main.go new file mode 100644 index 0000000..e784876 --- /dev/null +++ b/examples/cgi/main.go @@ -0,0 +1,49 @@ +package main + +import ( + "context" + "log" + "os" + + "tildegit.org/tjp/gus/contrib/cgi" + guslog "tildegit.org/tjp/gus/contrib/log" + "tildegit.org/tjp/gus/gemini" +) + +func main() { + // GET TLS files from the environment + certfile, keyfile := envConfig() + + // build a TLS configuration suitable for gemini + tlsconf, err := gemini.FileTLS(certfile, keyfile) + if err != nil { + log.Fatal(err) + } + + // make use of a CGI request handler + cgiHandler := cgi.CGIHandler("/cgi-bin", "cgi-bin") + + // add stdout logging to the request handler + handler := guslog.Requests(os.Stdout, nil)(cgiHandler) + + // run the server + server, err := gemini.NewServer(context.Background(), tlsconf, "tcp4", ":1965", handler) + if err != nil { + log.Fatal(err) + } + server.Serve() +} + +func envConfig() (string, string) { + certfile, ok := os.LookupEnv("SERVER_CERTIFICATE") + if !ok { + log.Fatal("missing SERVER_CERTIFICATE environment variable") + } + + keyfile, ok := os.LookupEnv("SERVER_PRIVATEKEY") + if !ok { + log.Fatal("missing SERVER_PRIVATEKEY environment variable") + } + + return certfile, keyfile +} diff --git a/examples/cowsay/main.go b/examples/cowsay/main.go index e724421..be81f50 100644 --- a/examples/cowsay/main.go +++ b/examples/cowsay/main.go @@ -5,7 +5,6 @@ import ( "context" "io" "log" - "net" "os" "os/exec" @@ -23,17 +22,15 @@ func main() { log.Fatal(err) } - // set up the network listener - listener, err := net.Listen("tcp4", ":1965") - if err != nil { - log.Fatal(err) - } - // add request logging to the request handler handler := guslog.Requests(os.Stdout, nil)(cowsayHandler) // run the server - gemini.NewServer(context.Background(), tlsconf, listener, handler).Serve() + server, err := gemini.NewServer(context.Background(), tlsconf, "tcp4", ":1965", handler) + if err != nil { + log.Fatal(err) + } + server.Serve() } func cowsayHandler(ctx context.Context, req *gemini.Request) *gemini.Response { diff --git a/examples/fileserver/main.go b/examples/fileserver/main.go index 01d22ee..d7f628b 100644 --- a/examples/fileserver/main.go +++ b/examples/fileserver/main.go @@ -3,7 +3,6 @@ package main import ( "context" "log" - "net" "os" "tildegit.org/tjp/gus/contrib/fs" @@ -21,12 +20,6 @@ func main() { log.Fatal(err) } - // set up the network listen - listener, err := net.Listen("tcp4", ":1965") - if err != nil { - log.Fatal(err) - } - // build the request handler fileSystem := os.DirFS(".") // Fallthrough tries each handler in succession until it gets something other than "51 Not Found" @@ -42,7 +35,11 @@ func main() { handler = guslog.Requests(os.Stdout, nil)(handler) // run the server - gemini.NewServer(context.Background(), tlsconf, listener, handler).Serve() + server, err := gemini.NewServer(context.Background(), tlsconf, "tcp4", ":1965", handler) + if err != nil { + log.Fatal(err) + } + server.Serve() } func envConfig() (string, string) { diff --git a/examples/inspectls/main.go b/examples/inspectls/main.go index a315e40..d690af1 100644 --- a/examples/inspectls/main.go +++ b/examples/inspectls/main.go @@ -9,12 +9,11 @@ import ( "encoding/hex" "fmt" "log" - "net" "os" "strings" - "tildegit.org/tjp/gus/gemini" guslog "tildegit.org/tjp/gus/contrib/log" + "tildegit.org/tjp/gus/gemini" ) func main() { @@ -27,17 +26,15 @@ func main() { log.Fatal(err) } - // set up the network listener - listener, err := net.Listen("tcp4", ":1965") - if err != nil { - log.Fatal(err) - } - // add stdout logging to the request handler handler := guslog.Requests(os.Stdout, nil)(inspectHandler) // run the server - gemini.NewServer(context.Background(), tlsconf, listener, handler).Serve() + server, err := gemini.NewServer(context.Background(), tlsconf, "tcp4", ":1965", handler) + if err != nil { + log.Fatal(err) + } + server.Serve() } func envConfig() (string, string) { diff --git a/gemini/request.go b/gemini/request.go index 248ce67..43ee69b 100644 --- a/gemini/request.go +++ b/gemini/request.go @@ -5,6 +5,7 @@ import ( "crypto/tls" "errors" "io" + "net" "net/url" ) @@ -15,7 +16,9 @@ var InvalidRequestLineEnding = errors.New("invalid request line ending") type Request struct { *url.URL - TLSState *tls.ConnectionState + Server *Server + RemoteAddr net.Addr + TLSState *tls.ConnectionState } // ParseRequest parses a single gemini request from a reader. diff --git a/gemini/response.go b/gemini/response.go index 90340a5..478913b 100644 --- a/gemini/response.go +++ b/gemini/response.go @@ -1,7 +1,9 @@ package gemini import ( + "bufio" "bytes" + "errors" "io" "strconv" ) @@ -262,6 +264,39 @@ func CertInvalid(msg string) *Response { } } +// InvalidResponseLineEnding indicates that a gemini response header didn't end with "\r\n". +var InvalidResponseLineEnding = errors.New("Invalid response line ending.") + +// InvalidResponseHeaderLine indicates a malformed gemini response header line. +var InvalidResponseHeaderLine = errors.New("Invalid response header line.") + +// ParseResponse parses a complete gemini response from a reader. +// +// The reader must contain only one gemini response. +func ParseResponse(rdr io.Reader) (*Response, error) { + bufrdr := bufio.NewReader(rdr) + + hdrLine, err := bufrdr.ReadBytes('\n') + if err != nil { + return nil, InvalidResponseLineEnding + } + if hdrLine[len(hdrLine)-2] != '\r' { + return nil, InvalidResponseLineEnding + } + hdrLine = hdrLine[:len(hdrLine)-2] + + status, err := strconv.Atoi(string(hdrLine[:2])) + if err != nil { + return nil, InvalidResponseHeaderLine + } + + return &Response{ + Status: Status(status), + Meta: string(hdrLine[2:]), + Body: bufrdr, + }, nil +} + // Read implements io.Reader for Response. func (r *Response) Read(b []byte) (int, error) { r.ensureReader() diff --git a/gemini/serve.go b/gemini/serve.go index d439472..8fd6b57 100644 --- a/gemini/serve.go +++ b/gemini/serve.go @@ -10,17 +10,33 @@ import ( type Server struct { ctx context.Context + network string + address string cancel context.CancelFunc wg *sync.WaitGroup listener net.Listener handler Handler } -func NewServer(ctx context.Context, tlsConfig *tls.Config, listener net.Listener, handler Handler) *Server { +func NewServer( + ctx context.Context, + tlsConfig *tls.Config, + network string, + address string, + handler Handler, +) (*Server, error) { ctx, cancel := context.WithCancel(ctx) + listener, err := net.Listen(network, address) + if err != nil { + cancel() + return nil, err + } + s := &Server{ ctx: ctx, + network: network, + address: address, cancel: cancel, wg: &sync.WaitGroup{}, listener: tls.NewListener(listener, tlsConfig), @@ -28,7 +44,7 @@ func NewServer(ctx context.Context, tlsConfig *tls.Config, listener net.Listener } go s.propagateCancel() - return s + return s, nil } func (s *Server) Close() { @@ -51,22 +67,42 @@ func (s *Server) Serve() { } } +func (s *Server) Network() string { + return s.network +} + +func (s *Server) Address() string { + return s.address +} + +func (s *Server) Hostname() string { + host, _, _ := net.SplitHostPort(s.address) + return host +} + +func (s *Server) Port() string { + _, portStr, _ := net.SplitHostPort(s.address) + return portStr +} + func (s *Server) handleConn(conn net.Conn) { defer s.wg.Done() defer conn.Close() req, err := ParseRequest(conn) + if err != nil { + _, _ = io.Copy(conn, BadRequest(err.Error())) + return + } + + req.Server = s + req.RemoteAddr = conn.RemoteAddr() if tlsconn, ok := conn.(*tls.Conn); req != nil && ok { state := tlsconn.ConnectionState() req.TLSState = &state } - var resp *Response - if err == nil { - resp = s.handler(s.ctx, req) - } else { - resp = BadRequest(err.Error()) - } + resp := s.handler(s.ctx, req) defer resp.Close() _, _ = io.Copy(conn, resp) -- cgit v1.2.3