From 75cfbc0c65558cef8bc7b565cff102e439b08a18 Mon Sep 17 00:00:00 2001 From: "kim (grufwub)" Date: Sat, 11 Jul 2020 21:59:44 +0100 Subject: [PATCH] gophor rewrite Signed-off-by: kim (grufwub) --- build-all.sh => build-all-gopher.sh | 68 +++--- build-gopher.sh | 3 + config.go | 46 ---- conn.go | 175 -------------- core/cache.go | 77 ++++++ core/cgi.go | 331 ++++++++++++++++++++++++++ core/client.go | 45 ++++ core/conn.go | 120 ++++++++++ core/error.go | 147 ++++++++++++ core/file.go | 81 +++++++ core/filecontents.go | 48 ++++ core/filesystem.go | 343 +++++++++++++++++++++++++++ core/host.go | 18 ++ core/listener.go | 37 +++ core/logger.go | 92 ++++++++ core/path.go | 118 ++++++++++ core/regex.go | 159 +++++++++++++ core/request.go | 25 ++ core/server.go | 208 ++++++++++++++++ core/string_constants.go | 151 ++++++++++++ core/url.go | 75 ++++++ core/util.go | 22 ++ error.go | 251 -------------------- exec.go | 160 ------------- filecontents.go | 353 ---------------------------- filesystem.go | 351 --------------------------- filesystem_read.go | 202 ---------------- fixedmap.go | 78 ------ format.go | 50 ---- gopher.go | 237 ------------------- gopher/error.go | 92 ++++++++ gopher/filecontents.go | 36 +++ gopher/format.go | 96 ++++++++ gopher/gophermap.go | 240 +++++++++++++++++++ gopher/html.go | 22 ++ gopher/itemtype.go | 192 +++++++++++++++ gopher/main.go | 37 +++ gopher/policy.go | 48 ++++ gopher/regex.go | 21 ++ gopher/server.go | 110 +++++++++ gopher/string_constants.go | 49 ++++ gophor.go | 254 -------------------- html.go | 22 -- http.go | 209 ---------------- logger.go | 157 ------------- main_gopher.go | 9 + parse.go | 154 ------------ policy.go | 96 -------- regex.go | 94 -------- request.go | 130 ---------- responder.go | 54 ----- worker.go | 71 ------ 52 files changed, 3092 insertions(+), 3172 deletions(-) rename build-all.sh => build-all-gopher.sh (85%) create mode 100755 build-gopher.sh delete mode 100644 config.go delete mode 100644 conn.go create mode 100644 core/cache.go create mode 100644 core/cgi.go create mode 100644 core/client.go create mode 100644 core/conn.go create mode 100644 core/error.go create mode 100644 core/file.go create mode 100644 core/filecontents.go create mode 100644 core/filesystem.go create mode 100644 core/host.go create mode 100644 core/listener.go create mode 100644 core/logger.go create mode 100644 core/path.go create mode 100644 core/regex.go create mode 100644 core/request.go create mode 100644 core/server.go create mode 100644 core/string_constants.go create mode 100644 core/url.go create mode 100644 core/util.go delete mode 100644 error.go delete mode 100644 exec.go delete mode 100644 filecontents.go delete mode 100644 filesystem.go delete mode 100644 filesystem_read.go delete mode 100644 fixedmap.go delete mode 100644 format.go delete mode 100644 gopher.go create mode 100644 gopher/error.go create mode 100644 gopher/filecontents.go create mode 100644 gopher/format.go create mode 100644 gopher/gophermap.go create mode 100644 gopher/html.go create mode 100644 gopher/itemtype.go create mode 100644 gopher/main.go create mode 100644 gopher/policy.go create mode 100644 gopher/regex.go create mode 100644 gopher/server.go create mode 100644 gopher/string_constants.go delete mode 100644 gophor.go delete mode 100644 html.go delete mode 100644 http.go delete mode 100644 logger.go create mode 100644 main_gopher.go delete mode 100644 parse.go delete mode 100644 policy.go delete mode 100644 regex.go delete mode 100644 request.go delete mode 100644 responder.go delete mode 100644 worker.go diff --git a/build-all.sh b/build-all-gopher.sh similarity index 85% rename from build-all.sh rename to build-all-gopher.sh index 12a3a6e..cffaaf9 100755 --- a/build-all.sh +++ b/build-all-gopher.sh @@ -2,14 +2,45 @@ set -e -PROJECT='gophor' -VERSION="$(cat 'gophor.go' | grep -E '^\s*GophorVersion' | sed -e 's|\s*GophorVersion = \"||' -e 's|\"\s*$||')" -GOVERSION="$(go version | sed -e 's|^go version go||' -e 's|\s.*$||')" +PROJECT='gophor.gopher' +#VERSION="$(cat 'gophor.go' | grep -E '^\s*GophorVersion' | sed -e 's|\s*GophorVersion = \"||' -e 's|\"\s*$||')" +VERSION='v0.02-alpha' LOGFILE='build.log' -OUTDIR="build-${VERSION}" +OUTDIR="build-gopher-${VERSION}" -silent() { - "$@" >> "$LOGFILE" 2>&1 +upx_compress() { + local level="$1" filename="$2" topack="${2}.topack" + + cp "$filename" "$topack" + + if (upx "$level" "$topack" >> "$LOGFILE" 2>&1); then + if (upx --test "$topack"); then + mv "$topack" "$filename" + return 0 + else + rm "$topack" + return 1 + fi + else + rm "$topack" + return 1 + fi +} + +compress() { + local filename="$1" + + echo "Attempting to compress ${filename}..." + + if (upx_compress '--ultra-brute' "$filename"); then + echo "Compressed with --ultra-brute!" + elif (upx_compress '--best' "$filename"); then + echo "Compressed with --best!" + elif (upx_compress '' "$filename"); then + echo "Compressed with no flags." + else + echo "Compression failed!" + fi } build_for() { @@ -20,34 +51,15 @@ build_for() { shift 1 fi - echo "Building for ${os} ${archname}..." + echo "Building for ${os} ${archname} with ${toolchain}..." local filename="${OUTDIR}/${PROJECT}_${os}_${archname}" - CGO_ENABLED=1 CC="$toolchain-gcc" GOOS="$os" GOARCH="$arch" GOARM="$armversion" silent go build -trimpath -o "$filename" "$@" + CC="${toolchain}-gcc" CGO_ENABLED=1 GOOS="$os" GOARCH="$arch" GOARM="$armversion" go build -trimpath -o "$filename" "$@" 'main_gopher.go' >> "$LOGFILE" 2>&1 if [ "$?" -ne 0 ]; then echo "Failed!" return 1 fi - echo "Attempting to compress ${filename}..." - - # First try compression with --best - cp "$filename" "${filename}.topack" - if (silent upx --best "${filename}.topack") && (silent upx -t "${filename}.topack"); then - echo "Succeeded with best compression levels!" - mv "${filename}.topack" "$filename" - else - # Failed! Before throwing in the towel, try regular compression levels - cp "$filename" "${filename}.topack" - if (silent upx "${filename}.topack") && (silent upx -t "${filename}.topack"); then - echo "Succeeded with regular compression levels!" - mv "${filename}.topack" "$filename" - else - echo "Failed!" - rm "${filename}.topack" - fi - fi - - echo "" + compress "$filename" } echo "PLEASE BE WARNED THIS SCRIPT IS WRITTEN FOR A VOID LINUX (MUSL) BUILD ENVIRONMENT" diff --git a/build-gopher.sh b/build-gopher.sh new file mode 100755 index 0000000..ca857f4 --- /dev/null +++ b/build-gopher.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +CC='x86_64-linux-musl-gcc' CGO_ENABLED=1 go build -trimpath -buildmode 'pie' -a -tags 'netgo' -ldflags '-s -w -extldflags "-static"' -o gophor.gopher main_gopher.go \ No newline at end of file diff --git a/config.go b/config.go deleted file mode 100644 index 99a6cd3..0000000 --- a/config.go +++ /dev/null @@ -1,46 +0,0 @@ -package main - -import ( - "time" - "regexp" -) - -/* ServerConfig: - * Holds onto global server configuration details - * and any data objects we want to keep in memory - * (e.g. loggers, restricted files regular expressions - * and file cache) - */ -type ServerConfig struct { - /* Executable Settings */ - Env []string - CgiEnv []string - CgiEnabled bool - MaxExecRunTime time.Duration - - /* Content settings */ - FooterText []byte - PageWidth int - - /* Logging */ - SysLog LoggerInterface - AccLog LoggerInterface - - /* Filesystem access */ - FileSystem *FileSystem - - /* Buffer sizes */ - SocketWriteBufSize int - SocketReadBufSize int - SocketReadMax int - SkipPrefixBufSize int - FileReadBufSize int - - /* Socket deadlines */ - SocketReadDeadline time.Duration - SocketWriteDeadline time.Duration - - /* Precompiled regular expressions */ - RgxGophermap *regexp.Regexp - RgxCgiBin *regexp.Regexp -} diff --git a/conn.go b/conn.go deleted file mode 100644 index bc835ec..0000000 --- a/conn.go +++ /dev/null @@ -1,175 +0,0 @@ -package main - -import ( - "io" - "net" - "time" - "bufio" - "strconv" -) - -type ConnHost struct { - /* Hold host specific details */ - name string - hostport string - fwdport string -} - -func (host *ConnHost) Name() string { - return host.name -} - -func (host *ConnHost) Port() string { - return host.fwdport -} - -func (host *ConnHost) RealPort() string { - return host.hostport -} - -type ConnClient struct { - /* Hold client specific details */ - ip string - port string -} - -func (client *ConnClient) Ip() string { - return client.ip -} - -func (client *ConnClient) Port() string { - return client.port -} - -func (client *ConnClient) AddrStr() string { - return client.Ip()+":"+client.Port() -} - -type GophorListener struct { - /* Simple net.Listener wrapper that holds onto virtual - * host information + generates Worker instances on Accept() - */ - - Listener net.Listener - Host *ConnHost - Root string -} - -func BeginGophorListen(bindAddr, hostname, port, fwdPort, rootDir string) (*GophorListener, error) { - gophorListener := new(GophorListener) - gophorListener.Host = &ConnHost{ hostname, port, fwdPort } - gophorListener.Root = rootDir - - var err error - gophorListener.Listener, err = net.Listen("tcp", bindAddr+":"+port) - if err != nil { - return nil, err - } else { - return gophorListener, nil - } -} - -func (l *GophorListener) Accept() (*Worker, error) { - conn, err := l.Listener.Accept() - if err != nil { - return nil, err - } - - /* Should always be ok as listener is type TCP (see above) */ - addr, _ := conn.RemoteAddr().(*net.TCPAddr) - client := &ConnClient{ addr.IP.String(), strconv.Itoa(addr.Port) } - - return &Worker{ NewBufferedDeadlineConn(conn), l.Host, client, l.Root }, nil -} - -type DeadlineConn struct { - /* Simple wrapper to net.Conn that sets deadlines - * on each call to Read() / Write() - */ - - conn net.Conn -} - -func NewDeadlineConn(conn net.Conn) *DeadlineConn { - return &DeadlineConn{ conn } -} - -func (c *DeadlineConn) Read(b []byte) (int, error) { - /* Implements a regular net.Conn + updates deadline */ - c.conn.SetReadDeadline(time.Now().Add(Config.SocketReadDeadline)) - return c.conn.Read(b) -} - -func (c *DeadlineConn) Write(b []byte) (int, error) { - /* Implements a regular net.Conn + updates deadline */ - c.conn.SetWriteDeadline(time.Now().Add(Config.SocketWriteDeadline)) - return c.conn.Write(b) -} - -func (c *DeadlineConn) Close() error { - /* Close */ - return c.conn.Close() -} - -type BufferedDeadlineConn struct { - /* Wrapper around DeadlineConn that provides buffered - * reads and writes. - */ - - conn *DeadlineConn - buffer *bufio.ReadWriter -} - -func NewBufferedDeadlineConn(conn net.Conn) *BufferedDeadlineConn { - deadlineConn := NewDeadlineConn(conn) - return &BufferedDeadlineConn{ - deadlineConn, - bufio.NewReadWriter( - bufio.NewReaderSize(deadlineConn, Config.SocketReadBufSize), - bufio.NewWriterSize(deadlineConn, Config.SocketWriteBufSize), - ), - } -} - -func (c *BufferedDeadlineConn) ReadLine() ([]byte, error) { - /* Return slice */ - b := make([]byte, 0) - - for len(b) < Config.SocketReadMax { - /* Read line */ - line, isPrefix, err := c.buffer.ReadLine() - if err != nil { - return nil, err - } - - /* Add to return slice */ - b = append(b, line...) - - /* If !isPrefix, we can break-out */ - if !isPrefix { - break - } - } - - return b, nil -} - -func (c *BufferedDeadlineConn) Write(b []byte) (int, error) { - return c.buffer.Write(b) -} - -func (c *BufferedDeadlineConn) WriteData(b []byte) error { - _, err := c.buffer.Write(b) - return err -} - -func (c *BufferedDeadlineConn) WriteRaw(r io.Reader) error { - _, err := c.buffer.ReadFrom(r) - return err -} - -func (c *BufferedDeadlineConn) Close() error { - /* First flush buffer, then close */ - c.buffer.Flush() - return c.conn.Close() -} diff --git a/core/cache.go b/core/cache.go new file mode 100644 index 0000000..31de43d --- /dev/null +++ b/core/cache.go @@ -0,0 +1,77 @@ +package core + +import "container/list" + +// element wraps a map key and value +type element struct { + key string + value *file +} + +// lruCacheMap is a fixed-size LRU hash map +type lruCacheMap struct { + hashMap map[string]*list.Element + list *list.List + size int +} + +// newLRUCacheMap returns a new LRUCacheMap of specified size +func newLRUCacheMap(size int) *lruCacheMap { + return &lruCacheMap{ + // size+1 to account for moment during put after adding new value but before old value is purged + make(map[string]*list.Element, size+1), + &list.List{}, + size, + } +} + +// Get returns file from LRUCacheMap for key +func (lru *lruCacheMap) Get(key string) (*file, bool) { + lElem, ok := lru.hashMap[key] + if !ok { + return nil, ok + } + + // Move element to front of the list + lru.list.MoveToFront(lElem) + + // Get Element and return *File value from it + element, _ := lElem.Value.(*element) + return element.value, ok +} + +// Put file in LRUCacheMap at key +func (lru *lruCacheMap) Put(key string, value *file) { + lElem := lru.list.PushFront(&element{key, value}) + lru.hashMap[key] = lElem + + if lru.list.Len() > lru.size { + // Get element at back of list and Element from it + lElem = lru.list.Back() + element, _ := lElem.Value.(*element) + + // Delete entry in hashMap with key from Element, and from list + delete(lru.hashMap, element.key) + lru.list.Remove(lElem) + } +} + +// Remove file in LRUCacheMap with key +func (lru *lruCacheMap) Remove(key string) { + lElem, ok := lru.hashMap[key] + if !ok { + return + } + + // Delete entry in hashMap and list + delete(lru.hashMap, key) + lru.list.Remove(lElem) +} + +// Iterate performs an iteration over all key:value pairs in LRUCacheMap with supplied function +func (lru *lruCacheMap) Iterate(iterator func(key string, value *file)) { + for key := range lru.hashMap { + element, _ := lru.hashMap[key].Value.(*element) + iterator(element.key, element.value) + } +} diff --git a/core/cgi.go b/core/cgi.go new file mode 100644 index 0000000..d9a272a --- /dev/null +++ b/core/cgi.go @@ -0,0 +1,331 @@ +package core + +import ( + "bytes" + "io" + "os/exec" + "strings" + "syscall" + "time" +) + +var ( + // cgiEnv holds the global slice of constant CGI environment variables + cgiEnv []string + + // maxCGIRunTime specifies the maximum time a CGI script can run for + maxCGIRunTime time.Duration + + // httpPrefixBufSize specifies size of the buffer to use when skipping HTTP headers + httpPrefixBufSize int + + // ExecuteCGIScript is a pointer to the currently set CGI execution function + ExecuteCGIScript func(*Client, *Request) Error +) + +// setupInitialCGIEnv takes a safe PATH, uses other server variables and returns a slice of constant CGI environment variables +func setupInitialCGIEnv(safePath string) []string { + env := make([]string, 0) + + SystemLog.Info("CGI safe path: %s", safePath) + env = append(env, "PATH="+safePath) + env = append(env, "SERVER_NAME="+Hostname) + env = append(env, "SERVER_PORT="+FwdPort) + env = append(env, "DOCUMENT_ROOT="+Root) + + return env +} + +// generateCGIEnv takes a Client, and Request object, the global constant slice and generates a full set of CGI environment variables +func generateCGIEnv(client *Client, request *Request) []string { + env := append(cgiEnv, "REMOTE_ADDR="+client.IP()) + env = append(env, "QUERY_STRING="+request.Params()) + env = append(env, "SCRIPT_NAME="+request.Path().Relative()) + env = append(env, "SCRIPT_FILENAME="+request.Path().Absolute()) + env = append(env, "SELECTOR="+request.Path().Selector()) + env = append(env, "REQUEST_URI="+request.Path().Selector()) + + return env +} + +// executeCGIScriptNoHTTP executes a CGI script, responding with output to client without stripping HTTP headers +func executeCGIScriptNoHTTP(client *Client, request *Request) Error { + return execute(client.Conn().Writer(), request.Path(), generateCGIEnv(client, request)) +} + +// executeCGIScriptStripHTTP executes a CGI script, responding with output to client, stripping HTTP headers and handling status code +func executeCGIScriptStripHTTP(client *Client, request *Request) Error { + // Create new httpStripWriter + httpWriter := newhttpStripWriter(client.Conn().Writer()) + + // Begin executing script + err := execute(httpWriter, request.Path(), generateCGIEnv(client, request)) + + // Parse HTTP headers (if present). Return error or continue letting output of script -> client + cgiStatusErr := httpWriter.FinishUp() + if cgiStatusErr != nil { + return cgiStatusErr + } + return err +} + +// execute executes something at Path, with supplied environment and ouputing to writer +func execute(writer io.Writer, p *Path, env []string) Error { + // Create cmd object + cmd := exec.Command(p.Absolute()) + + // Set new process group id + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + // Setup cmd environment + cmd.Env, cmd.Dir = env, p.Root() + + // Setup cmd out writer + cmd.Stdout = writer + + // Start executing + err := cmd.Start() + if err != nil { + return WrapError(CGIStartErr, err) + } + + // Setup goroutine to kill cmd after maxCGIRunTime + go func() { + // At least let the script try to finish... + time.Sleep(maxCGIRunTime) + + // We've already finished + if cmd.ProcessState != nil { + return + } + + // Get process group id + pgid, err := syscall.Getpgid(cmd.Process.Pid) + if err != nil { + SystemLog.Fatal(pgidNotFoundErrStr) + } + + // Kill process group! + err = syscall.Kill(-pgid, syscall.SIGTERM) + if err != nil { + SystemLog.Fatal(pgidStopErrStr, pgid, err.Error()) + } + }() + + // Wait for command to finish, get exit code + err = cmd.Wait() + exitCode := 0 + if err != nil { + // Error, try to get exit code + exitError, ok := err.(*exec.ExitError) + if ok { + waitStatus := exitError.Sys().(syscall.WaitStatus) + exitCode = waitStatus.ExitStatus() + } else { + exitCode = 1 + } + } else { + // No error! Get exit code directly from command process state + waitStatus := cmd.ProcessState.Sys().(syscall.WaitStatus) + exitCode = waitStatus.ExitStatus() + } + + // Non-zero exit code? Return error + if exitCode != 0 { + SystemLog.Error(cgiExecuteErrStr, p.Absolute(), exitCode) + return NewError(CGIExitCodeErr) + } + + // Exit fine! + return nil +} + +// httpStripWriter wraps a writer, reading HTTP headers and parsing status code, before deciding to continue writing +type httpStripWriter struct { + writer io.Writer + skipBuffer []byte + skipIndex int + err Error + + // writeFunc is a pointer to the current underlying write function + writeFunc func(*httpStripWriter, []byte) (int, error) +} + +// newhttpStripWriter returns a new httpStripWriter wrapping supplied writer +func newhttpStripWriter(w io.Writer) *httpStripWriter { + return &httpStripWriter{ + w, + make([]byte, httpPrefixBufSize), + 0, + nil, + writeCheckForHeaders, + } +} + +// addToSkipBuffer adds supplied bytes to the skip buffer, returning number added +func (w *httpStripWriter) addToSkipBuffer(data []byte) int { + // Figure out amount to add + toAdd := len(w.skipBuffer) - w.skipIndex + if len(data) < toAdd { + toAdd = len(data) + } + + // Add data to skip buffer, return added + copy(w.skipBuffer[w.skipIndex:], data[:toAdd]) + w.skipIndex += toAdd + return toAdd +} + +// parseHTTPHeaderSection checks if we've received a valid HTTP header section, and determine if we should continue writing +func (w *httpStripWriter) parseHTTPHeaderSection() (bool, bool) { + validHeaderSection, shouldContinue := false, true + for _, header := range strings.Split(string(w.skipBuffer), "\r\n") { + header = strings.ToLower(header) + + // Try look for status header + lenBefore := len(header) + header = strings.TrimPrefix(header, "status:") + if len(header) < lenBefore { + // Ensure no spaces + just number + header = strings.Split(header, " ")[0] + + // Ignore 200 + if header == "200" { + continue + } + + // Any other value indicates error, should not continue + shouldContinue = false + + // Parse error code + code := CGIStatusUnknownErr + switch header { + case "400": + code = CGIStatus400Err + case "401": + code = CGIStatus401Err + case "403": + code = CGIStatus403Err + case "404": + code = CGIStatus404Err + case "408": + code = CGIStatus408Err + case "410": + code = CGIStatus410Err + case "500": + code = CGIStatus500Err + case "501": + code = CGIStatus501Err + case "503": + code = CGIStatus503Err + } + + // Set error code + w.err = NewError(code) + continue + } + + // Found a content-type header, this is a valid header section + if strings.Contains(header, "content-type:") { + validHeaderSection = true + } + } + + return validHeaderSection, shouldContinue +} + +// writeSkipBuffer writes contents of skipBuffer to the underlying writer if necessary +func (w *httpStripWriter) writeSkipBuffer() (bool, error) { + // Defer resetting skipIndex + defer func() { + w.skipIndex = 0 + }() + + // First try parse the headers, determine next steps + validHeaders, shouldContinue := w.parseHTTPHeaderSection() + + // Valid headers received, don't bother writing. Return the shouldContinue value + if validHeaders { + return shouldContinue, nil + } + + // Default is to write skip buffer contents, shouldContinue only means something with valid headers + _, err := w.writer.Write(w.skipBuffer[:w.skipIndex]) + return true, err +} + +func (w *httpStripWriter) FinishUp() Error { + // If skipIndex not zero, try write (or at least parse and see if we need + // to write) remaining skipBuffer. (e.g. if CGI output very short) + if w.skipIndex > 0 { + w.writeSkipBuffer() + } + + // Return error if set + return w.err +} + +func (w *httpStripWriter) Write(b []byte) (int, error) { + // Write using currently set write function + return w.writeFunc(w, b) +} + +// writeRegular performs task of regular write function, it is a direct wrapper +func writeRegular(w *httpStripWriter, b []byte) (int, error) { + return w.writer.Write(b) +} + +// writeCheckForHeaders reads input data, checking for headers to add to skip buffer and parse before continuing +func writeCheckForHeaders(w *httpStripWriter, b []byte) (int, error) { + split := bytes.Split(b, []byte("\r\n\r\n")) + if len(split) == 1 { + // Headers found, try to add data to skip buffer + added := w.addToSkipBuffer(b) + + if added < len(b) { + defer func() { + // Having written skip buffer, defer resetting write function + w.writeFunc = writeRegular + }() + + doContinue, err := w.writeSkipBuffer() + if !doContinue { + return len(b), io.EOF + } else if err != nil { + return added, err + } + + // Write remaining data not added to skip buffer + count, err := w.writer.Write(b[added:]) + if err != nil { + return added + count, err + } + } + + return len(b), nil + } + + defer func() { + // No use for skip buffer after belo, set write to regular + w.writeFunc = writeRegular + }() + + // Try add what we can to skip buffer + added := w.addToSkipBuffer(append(split[0], []byte("\r\n\r\n")...)) + + // Write skip buffer data if necessary, check if we should continue + doContinue, err := w.writeSkipBuffer() + if !doContinue { + return len(b), io.EOF + } else if err != nil { + return added, err + } + + // Write remaining data not added to skip buffer, to writer + count, err := w.writer.Write(b[added:]) + if err != nil { + return added + count, err + } + + return len(b), nil +} diff --git a/core/client.go b/core/client.go new file mode 100644 index 0000000..576cdba --- /dev/null +++ b/core/client.go @@ -0,0 +1,45 @@ +package core + +import ( + "net" + "strconv" +) + +// Client holds onto an open Conn to a client, along with connection information +type Client struct { + cn *conn + ip *net.IP + port string +} + +// NewClient returns a new client based on supplied net.TCPConn +func NewClient(conn *net.TCPConn) *Client { + addr, _ := conn.RemoteAddr().(*net.TCPAddr) + ip, port := &addr.IP, strconv.Itoa(addr.Port) + return &Client{wrapConn(conn), ip, port} +} + +// Conn returns the underlying conn +func (c *Client) Conn() *conn { + return c.cn +} + +// IP returns the client's IP string +func (c *Client) IP() string { + return c.ip.String() +} + +// Port returns the client's connected port +func (c *Client) Port() string { + return c.port +} + +// LogInfo logs to the global access logger with the client IP as a prefix +func (c *Client) LogInfo(fmt string, args ...interface{}) { + AccessLog.Info("("+c.ip.String()+") "+fmt, args...) +} + +// LogError logs to the global access logger with the client IP as a prefix +func (c *Client) LogError(fmt string, args ...interface{}) { + AccessLog.Error("("+c.ip.String()+") "+fmt, args...) +} diff --git a/core/conn.go b/core/conn.go new file mode 100644 index 0000000..fe99360 --- /dev/null +++ b/core/conn.go @@ -0,0 +1,120 @@ +package core + +import ( + "bufio" + "io" + "net" + "time" +) + +var ( + // connReadDeadline specifies the connection read deadline + connReadDeadline time.Duration + + // connWriteDeadline specifies the connection write deadline + connWriteDeadline time.Duration + + // connReadBufSize specifies the connection read buffer size + connReadBufSize int + + // connWriteBufSize specifies the connection write buffer size + connWriteBufSize int + + // connReadMax specifies the connection read max (in bytes) + connReadMax int +) + +// deadlineConn wraps net.Conn to set the read / write deadlines on each access +type deadlineConn struct { + conn net.Conn +} + +// Read wraps the underlying net.Conn read function, setting read deadline on each access +func (c *deadlineConn) Read(b []byte) (int, error) { + c.conn.SetReadDeadline(time.Now().Add(connReadDeadline)) + return c.conn.Read(b) +} + +// Read wraps the underlying net.Conn write function, setting write deadline on each access +func (c *deadlineConn) Write(b []byte) (int, error) { + c.conn.SetWriteDeadline(time.Now().Add(connWriteDeadline)) + return c.conn.Write(b) +} + +// Close directly wraps underlying net.Conn close function +func (c *deadlineConn) Close() error { + return c.conn.Close() +} + +// Conn wraps a DeadlineConn with a buffer +type conn struct { + buf *bufio.ReadWriter + closer io.Closer +} + +// wrapConn wraps a net.Conn in DeadlineConn, then within Conn and returns the result +func wrapConn(c net.Conn) *conn { + deadlineConn := &deadlineConn{c} + buf := bufio.NewReadWriter( + bufio.NewReaderSize(deadlineConn, connReadBufSize), + bufio.NewWriterSize(deadlineConn, connWriteBufSize), + ) + return &conn{buf, deadlineConn} +} + +// ReadLine reads a single line and returns the result, or nil and error +func (c *conn) ReadLine() ([]byte, Error) { + // return slice + b := make([]byte, 0) + + for len(b) < connReadMax { + // read the line + line, isPrefix, err := c.buf.ReadLine() + if err != nil { + return nil, WrapError(ConnReadErr, err) + } + + // append line contents to return slice + b = append(b, line...) + + // if finished reading, break out + if !isPrefix { + break + } + } + + return b, nil +} + +// WriteBytes writes a byte slice to the buffer and returns error status +func (c *conn) WriteBytes(b []byte) Error { + _, err := c.buf.Write(b) + if err != nil { + return WrapError(ConnWriteErr, err) + } + return nil +} + +// WriteFrom writes to the buffer from a reader and returns error status +func (c *conn) WriteFrom(r io.Reader) Error { + _, err := c.buf.ReadFrom(r) + if err != nil { + return WrapError(ConnWriteErr, err) + } + return nil +} + +// Writer returns the underlying buffer wrapped conn writer +func (c *conn) Writer() io.Writer { + return c.buf.Writer +} + +// Close flushes the underlying buffer then closes the conn +func (c *conn) Close() Error { + err := c.buf.Flush() + err = c.closer.Close() + if err != nil { + return WrapError(ConnCloseErr, err) + } + return nil +} diff --git a/core/error.go b/core/error.go new file mode 100644 index 0000000..99438ff --- /dev/null +++ b/core/error.go @@ -0,0 +1,147 @@ +package core + +// ErrorCode specifies types of errors for later identification +type ErrorCode int + +// Core ErrorCodes +const ( + ConnWriteErr ErrorCode = -1 + ConnReadErr ErrorCode = -2 + ConnCloseErr ErrorCode = -3 + ListenerResolveErr ErrorCode = -4 + ListenerBeginErr ErrorCode = -5 + ListenerAcceptErr ErrorCode = -6 + InvalidIPErr ErrorCode = -7 + InvalidPortErr ErrorCode = -8 + FileOpenErr ErrorCode = -9 + FileStatErr ErrorCode = -10 + FileReadErr ErrorCode = -11 + FileTypeErr ErrorCode = -12 + DirectoryReadErr ErrorCode = -13 + RestrictedPathErr ErrorCode = -14 + InvalidRequestErr ErrorCode = -15 + CGIStartErr ErrorCode = -16 + CGIExitCodeErr ErrorCode = -17 + CGIStatus400Err ErrorCode = -18 + CGIStatus401Err ErrorCode = -19 + CGIStatus403Err ErrorCode = -20 + CGIStatus404Err ErrorCode = -21 + CGIStatus408Err ErrorCode = -22 + CGIStatus410Err ErrorCode = -23 + CGIStatus500Err ErrorCode = -24 + CGIStatus501Err ErrorCode = -25 + CGIStatus503Err ErrorCode = -26 + CGIStatusUnknownErr ErrorCode = -27 +) + +// Error specifies error interface with identifiable ErrorCode +type Error interface { + Code() ErrorCode + Error() string +} + +// getExtendedErrorMessage converts an ErrorCode to string message +var getExtendedErrorMessage func(ErrorCode) string + +// getErrorMessage converts an ErrorCode to string message first checking internal codes, next user supplied +func getErrorMessage(code ErrorCode) string { + switch code { + case ConnWriteErr: + return connWriteErrStr + case ConnReadErr: + return connReadErrStr + case ConnCloseErr: + return connCloseErrStr + case ListenerResolveErr: + return listenerResolveErrStr + case ListenerBeginErr: + return listenerBeginErrStr + case ListenerAcceptErr: + return listenerAcceptErrStr + case InvalidIPErr: + return invalidIPErrStr + case InvalidPortErr: + return invalidPortErrStr + case FileOpenErr: + return fileOpenErrStr + case FileStatErr: + return fileStatErrStr + case FileReadErr: + return fileReadErrStr + case FileTypeErr: + return fileTypeErrStr + case DirectoryReadErr: + return directoryReadErrStr + case RestrictedPathErr: + return restrictedPathErrStr + case InvalidRequestErr: + return invalidRequestErrStr + case CGIStartErr: + return cgiStartErrStr + case CGIExitCodeErr: + return cgiExitCodeErrStr + case CGIStatus400Err: + return cgiStatus400ErrStr + case CGIStatus401Err: + return cgiStatus401ErrStr + case CGIStatus403Err: + return cgiStatus403ErrStr + case CGIStatus404Err: + return cgiStatus404ErrStr + case CGIStatus408Err: + return cgiStatus408ErrStr + case CGIStatus410Err: + return cgiStatus410ErrStr + case CGIStatus500Err: + return cgiStatus500ErrStr + case CGIStatus501Err: + return cgiStatus501ErrStr + case CGIStatus503Err: + return cgiStatus503ErrStr + case CGIStatusUnknownErr: + return cgiStatusUnknownErrStr + default: + return getExtendedErrorMessage(code) + } +} + +// regularError simply holds an ErrorCode +type regularError struct { + code ErrorCode +} + +// Error returns the error string for the underlying ErrorCode +func (e *regularError) Error() string { + return getErrorMessage(e.code) +} + +// Code returns the underlying ErrorCode +func (e *regularError) Code() ErrorCode { + return e.code +} + +// NewError returns a new Error based on supplied ErrorCode +func NewError(code ErrorCode) Error { + return ®ularError{code} +} + +// wrappedError wraps an existing error with new ErrorCode +type wrappedError struct { + code ErrorCode + err error +} + +// Error returns the error string for underlying error and set ErrorCode +func (e *wrappedError) Error() string { + return getErrorMessage(e.code) + " - " + e.err.Error() +} + +// Code returns the underlying ErrorCode +func (e *wrappedError) Code() ErrorCode { + return e.code +} + +// WrapError returns a new Error based on supplied error and ErrorCode +func WrapError(code ErrorCode, err error) Error { + return &wrappedError{code, err} +} diff --git a/core/file.go b/core/file.go new file mode 100644 index 0000000..61f6c27 --- /dev/null +++ b/core/file.go @@ -0,0 +1,81 @@ +package core + +import ( + "os" + "sync" + "time" +) + +// isGeneratedType just checks if a file's contents implemented is GeneratedFileContents +func isGeneratedType(f *file) bool { + switch f.contents.(type) { + case *generatedFileContents: + return true + default: + return false + } +} + +// file provides a structure for managing a cached file including freshness, last refresh time etc +type file struct { + contents FileContents + lastRefresh int64 + isFresh bool + sync.RWMutex +} + +// newFile returns a new File based on supplied FileContents +func newFile(contents FileContents) *file { + return &file{ + contents, + 0, + true, + sync.RWMutex{}, + } +} + +// IsFresh returns files freshness status +func (f *file) IsFresh() bool { + return f.isFresh +} + +// SetFresh sets the file as fresh +func (f *file) SetFresh() { + f.isFresh = true +} + +// SetUnfresh sets the file as unfresh +func (f *file) SetUnfresh() { + f.isFresh = false +} + +// LastRefresh gets the time in nanoseconds of last refresh +func (f *file) LastRefresh() int64 { + return f.lastRefresh +} + +// UpdateRefreshTime updates the lastRefresh time to the current time in nanoseconds +func (f *file) UpdateRefreshTime() { + f.lastRefresh = time.Now().UnixNano() +} + +// CacheContents caches the file contents using the supplied file descriptor +func (f *file) CacheContents(fd *os.File, path *Path) Error { + f.contents.Clear() + + // Load the file contents into cache + err := f.contents.Load(fd, path) + if err != nil { + return err + } + + // Set the cache freshness + f.UpdateRefreshTime() + f.SetFresh() + return nil +} + +// WriteToClient writes the cached file contents to the supplied client +func (f *file) WriteToClient(client *Client, path *Path) Error { + return f.contents.WriteToClient(client, path) +} diff --git a/core/filecontents.go b/core/filecontents.go new file mode 100644 index 0000000..1ba6973 --- /dev/null +++ b/core/filecontents.go @@ -0,0 +1,48 @@ +package core + +import "os" + +// FileContents provides an interface for caching, rendering and getting cached contents of a file +type FileContents interface { + WriteToClient(*Client, *Path) Error + Load(*os.File, *Path) Error + Clear() +} + +// generatedFileContents is a simple FileContents implementation for holding onto a generated (virtual) file contents +type generatedFileContents struct { + content []byte +} + +// WriteToClient writes the generated file contents to the client +func (fc *generatedFileContents) WriteToClient(client *Client, path *Path) Error { + return client.Conn().WriteBytes(fc.content) +} + +// Load does nothing +func (fc *generatedFileContents) Load(fd *os.File, path *Path) Error { return nil } + +// Clear does nothing +func (fc *generatedFileContents) Clear() {} + +// RegularFileContents is the simplest implementation of core.FileContents for regular files +type RegularFileContents struct { + contents []byte +} + +// WriteToClient writes the current contents of FileContents to the client +func (fc *RegularFileContents) WriteToClient(client *Client, path *Path) Error { + return client.Conn().WriteBytes(fc.contents) +} + +// Load takes an open FD and loads the file contents into FileContents memory +func (fc *RegularFileContents) Load(fd *os.File, path *Path) Error { + var err Error + fc.contents, err = FileSystem.ReadFile(fd) + return err +} + +// Clear empties currently cached FileContents memory +func (fc *RegularFileContents) Clear() { + fc.contents = nil +} diff --git a/core/filesystem.go b/core/filesystem.go new file mode 100644 index 0000000..940ec39 --- /dev/null +++ b/core/filesystem.go @@ -0,0 +1,343 @@ +package core + +import ( + "bufio" + "io" + "os" + "sort" + "sync" + "time" +) + +var ( + // FileReadBufSize is the file read buffer size + fileReadBufSize int + + // MonitorSleepTime is the duration the goroutine should periodically sleep before running file cache freshness checks + monitorSleepTime time.Duration + + // FileSizeMax is the maximum file size that is alloewd to be cached + fileSizeMax int64 + + // FileSystem is the global FileSystem object + FileSystem *FileSystemObject + + // userDir is the set subdir name to be looked for under user's home folders + userDir string +) + +// FileSystemObject holds onto an LRUCacheMap and manages access to it, handless freshness checking and multi-threading +type FileSystemObject struct { + cache *lruCacheMap + sync.RWMutex +} + +// NewFileSystemObject returns a new FileSystemObject +func newFileSystemObject(size int) *FileSystemObject { + return &FileSystemObject{ + newLRUCacheMap(size), + sync.RWMutex{}, + } +} + +// StartMonitor starts the FileSystemObject freshness check monitor in its own goroutine +func (fs *FileSystemObject) StartMonitor() { + for { + // Sleep to not take up all the precious CPU time :) + time.Sleep(monitorSleepTime) + + // Check file cache freshness + fs.checkCacheFreshness() + } +} + +// checkCacheFreshness iterates through FileSystemObject's cache and check for freshness +func (fs *FileSystemObject) checkCacheFreshness() { + // Before anything get cache lock + fs.Lock() + + fs.cache.Iterate(func(path string, f *file) { + // If this is a generated file we skip + if isGeneratedType(f) { + return + } + + // Check file still exists on disk + stat, err := os.Stat(path) + if err != nil { + SystemLog.Error("Failed to stat file in cache: %s\n", path) + fs.cache.Remove(path) + return + } + + // Get last mod time and check freshness + lastMod := stat.ModTime().UnixNano() + if f.IsFresh() && f.LastRefresh() < lastMod { + f.SetUnfresh() + } + }) + + // Done! Unlock (: + fs.Unlock() +} + +// OpenFile opens a file for reading (read-only, world-readable) +func (fs *FileSystemObject) OpenFile(p *Path) (*os.File, Error) { + fd, err := os.OpenFile(p.Absolute(), os.O_RDONLY, 0444) + if err != nil { + return nil, WrapError(FileOpenErr, err) + } + return fd, nil +} + +// StatFile performs a file stat on a file at path +func (fs *FileSystemObject) StatFile(p *Path) (os.FileInfo, Error) { + stat, err := os.Stat(p.Absolute()) + if err != nil { + return nil, WrapError(FileStatErr, err) + } + return stat, nil +} + +// ReadFile reads a supplied file descriptor into a return byte slice, or error +func (fs *FileSystemObject) ReadFile(fd *os.File) ([]byte, Error) { + // Return slice + ret := make([]byte, 0) + + // Read buffer + buf := make([]byte, fileReadBufSize) + + // Read through file until null bytes / error + for { + count, err := fd.Read(buf) + if err != nil { + if err == io.EOF { + break + } + return nil, WrapError(FileReadErr, err) + } + + ret = append(ret, buf[:count]...) + + if count < fileReadBufSize { + break + } + } + + return ret, nil +} + +// ScanFile scans a supplied file at file descriptor, using iterator function +func (fs *FileSystemObject) ScanFile(fd *os.File, iterator func(string) bool) Error { + // Buffered reader + rdr := bufio.NewReaderSize(fd, fileReadBufSize) + + // Iterate through file! + for { + // Line buffer + b := make([]byte, 0) + + // Read until line-end, or file end! + for { + // Read a line + line, isPrefix, err := rdr.ReadLine() + if err != nil { + if err == io.EOF { + break + } + return WrapError(FileReadErr, err) + } + + // Append to line buffer + b = append(b, line...) + + // If not isPrefix, we can break-out + if !isPrefix { + break + } + } + + // Run scan iterator on this line, break-out if requested + if !iterator(string(b)) { + break + } + } + + return nil +} + +// ScanDirectory reads the contents of a directory and performs the iterator function on each os.FileInfo entry returned +func (fs *FileSystemObject) ScanDirectory(fd *os.File, p *Path, iterator func(os.FileInfo, *Path)) Error { + dirList, err := fd.Readdir(-1) + if err != nil { + return WrapError(DirectoryReadErr, err) + } + + // Sort by name + sort.Sort(byName(dirList)) + + // Walk through the directory list using supplied iterator function + for _, info := range dirList { + // Make new Path object + fp := p.JoinPath(info.Name()) + + // Skip restricted files + if IsRestrictedPath(fp) || WithinCGIDir(fp) { + continue + } + + // Perform iterator + iterator(info, p.JoinPath(info.Name())) + } + + return nil +} + +// AddGeneratedFile adds a generated file content byte slice to the file cache, with supplied path as the key +func (fs *FileSystemObject) AddGeneratedFile(p *Path, b []byte) { + // Get write lock, defer unlock + fs.Lock() + defer fs.Unlock() + + // Create new generatedFileContents + contents := &generatedFileContents{b} + + // Wrap contents in File + file := newFile(contents) + + // Add to cache! + fs.cache.Put(p.Absolute(), file) +} + +// HandleClient handles a Client, attempting to serve their request from the filesystem whether a regular file, gophermap, dir listing or CGI script +func (fs *FileSystemObject) HandleClient(client *Client, request *Request, newFileContents func(*Path) FileContents, handleDirectory func(*FileSystemObject, *Client, *os.File, *Path) Error) Error { + // If restricted, return error + if IsRestrictedPath(request.Path()) { + return NewError(RestrictedPathErr) + } + + // Try remap request, log if so + ok := RemapRequest(request) + if ok { + client.LogInfo(requestRemappedStr, request.Path().Selector(), request.Params()) + } + + // First check for file on disk + fd, err := fs.OpenFile(request.Path()) + if err != nil { + // Get read-lock, defer unlock + fs.RLock() + defer fs.RUnlock() + + // Don't throw in the towel yet! Check for generated file in cache + file, ok := fs.cache.Get(request.Path().Absolute()) + if !ok { + return err + } + + // We got a generated file! Close and send as-is + return file.WriteToClient(client, request.Path()) + } + defer fd.Close() + + // Get stat + stat, goErr := fd.Stat() + if goErr != nil { + // Unlock, return error + fs.RUnlock() + return WrapError(FileStatErr, goErr) + } + + switch { + // Directory + case stat.Mode()&os.ModeDir != 0: + // Don't support CGI script dir enumeration + if WithinCGIDir(request.Path()) { + return NewError(RestrictedPathErr) + } + + // Else enumerate dir + return handleDirectory(fs, client, fd, request.Path()) + + // Regular file + case stat.Mode()&os.ModeType == 0: + // Execute script if within CGI dir + if WithinCGIDir(request.Path()) { + return ExecuteCGIScript(client, request) + } + + // Else just fetch + return fs.FetchFile(client, fd, stat, request.Path(), newFileContents) + + // Unsupported type + default: + return NewError(FileTypeErr) + } +} + +// FetchFile attempts to fetch a file from the cache, using the supplied file stat, Path and serving client. Returns Error status +func (fs *FileSystemObject) FetchFile(client *Client, fd *os.File, stat os.FileInfo, p *Path, newFileContents func(*Path) FileContents) Error { + // If file too big, write direct to client + if stat.Size() > fileSizeMax { + return client.Conn().WriteFrom(fd) + } + + // Get cache read lock, defer unlock + fs.RLock() + defer fs.RUnlock() + + // Now check for file in cache + f, ok := fs.cache.Get(p.Absolute()) + if !ok { + // Create new file contents with supplied function + contents := newFileContents(p) + + // Wrap contents in file + f = newFile(contents) + + // Cache the file contents + err := f.CacheContents(fd, p) + if err != nil { + // Unlock, return error + return err + } + + // Get cache write lock + fs.RUnlock() + fs.Lock() + + // Put file in cache + fs.cache.Put(p.Absolute(), f) + + // Switch back to cache read lock, get file read lock + fs.Unlock() + fs.RLock() + f.RLock() + } else { + // Get file read lock + f.RLock() + + // Check for file freshness + if !f.IsFresh() { + // Switch to file write lock + f.RUnlock() + f.Lock() + + // Refresh file contents + err := f.CacheContents(fd, p) + if err != nil { + // Unlock file, return error + f.Unlock() + return err + } + + // Done! Switch back to read lock + f.Unlock() + f.RLock() + } + } + + // Defer file unlock, write to client + defer f.RUnlock() + return f.WriteToClient(client, p) +} diff --git a/core/host.go b/core/host.go new file mode 100644 index 0000000..0a34383 --- /dev/null +++ b/core/host.go @@ -0,0 +1,18 @@ +package core + +var ( + // Root stores the server's root directory + Root string + + // BindAddr stores the server's bound IP + BindAddr string + + // Hostname stores the host's outward hostname + Hostname string + + // Port stores the internal port the host is binded to + Port string + + // FwdPort stores the host's outward port number + FwdPort string +) diff --git a/core/listener.go b/core/listener.go new file mode 100644 index 0000000..2cfa68b --- /dev/null +++ b/core/listener.go @@ -0,0 +1,37 @@ +package core + +import "net" + +// serverListener holds the global Listener object +var serverListener *listener + +// listener wraps a net.TCPListener to return our own clients on each Accept() +type listener struct { + l *net.TCPListener +} + +// NewListener returns a new Listener or Error +func newListener(ip, port string) (*listener, Error) { + // Try resolve provided ip and port details + laddr, err := net.ResolveTCPAddr("tcp", ip+":"+port) + if err != nil { + return nil, WrapError(ListenerResolveErr, err) + } + + // Create listener! + l, err := net.ListenTCP("tcp", laddr) + if err != nil { + return nil, WrapError(ListenerBeginErr, err) + } + + return &listener{l}, nil +} + +// Accept accepts a new connection and returns a client, or error +func (l *listener) Accept() (*Client, Error) { + conn, err := l.l.AcceptTCP() + if err != nil { + return nil, WrapError(ListenerAcceptErr, err) + } + return NewClient(conn), nil +} diff --git a/core/logger.go b/core/logger.go new file mode 100644 index 0000000..3c4fa5e --- /dev/null +++ b/core/logger.go @@ -0,0 +1,92 @@ +package core + +import ( + "log" + "os" +) + +var ( + // AccessLog holds a global access LogObject + AccessLog loggerInterface + + // SystemLog holds a global system LogObject + SystemLog loggerInterface +) + +func setupLogger(output string) loggerInterface { + switch output { + case "stdout": + return &stdLogger{} + case "null": + return &nullLogger{} + default: + fd, err := os.OpenFile(output, os.O_CREATE|os.O_APPEND, 0600) + if err != nil { + log.Fatalf(logOutputErrStr, output, err.Error()) + } + return &logger{log.New(fd, "", log.LstdFlags)} + } +} + +// LoggerInterface specifies an interface that can log different message levels +type loggerInterface interface { + Info(string, ...interface{}) + Error(string, ...interface{}) + Fatal(string, ...interface{}) +} + +// StdLogger implements LoggerInterface to log to output using regular log +type stdLogger struct{} + +// Info logs to log.Logger with info level prefix +func (l *stdLogger) Info(fmt string, args ...interface{}) { + log.Printf(":: I :: "+fmt, args...) +} + +// Error logs to log.Logger with error level prefix +func (l *stdLogger) Error(fmt string, args ...interface{}) { + log.Printf(":: E :: "+fmt, args...) +} + +// Fatal logs to standard log with fatal prefix and terminates program +func (l *stdLogger) Fatal(fmt string, args ...interface{}) { + log.Fatalf(":: F :: "+fmt, args...) +} + +// logger implements LoggerInterface to log to output using underlying log.Logger +type logger struct { + logger *log.Logger +} + +// Info logs to log.Logger with info level prefix +func (l *logger) Info(fmt string, args ...interface{}) { + l.logger.Printf("I :: "+fmt, args...) +} + +// Error logs to log.Logger with error level prefix +func (l *logger) Error(fmt string, args ...interface{}) { + l.logger.Printf("E :: "+fmt, args...) +} + +// Fatal logs to log.Logger with fatal prefix and terminates program +func (l *logger) Fatal(fmt string, args ...interface{}) { + l.logger.Fatalf("F :: "+fmt, args...) +} + +// nullLogger implements LoggerInterface to do absolutely fuck-all +type nullLogger struct{} + +// Info does nothing +func (l *nullLogger) Info(fmt string, args ...interface{}) { + // do nothing +} + +// Error does nothing +func (l *nullLogger) Error(fmt string, args ...interface{}) { + // do nothing +} + +// Fatal simply terminates the program +func (l *nullLogger) Fatal(fmt string, args ...interface{}) { + os.Exit(1) +} diff --git a/core/path.go b/core/path.go new file mode 100644 index 0000000..5510cbb --- /dev/null +++ b/core/path.go @@ -0,0 +1,118 @@ +package core + +import ( + "path" + "strings" +) + +// Path safely holds a file path +type Path struct { + root string // root dir + rel string // relative path + sel string // selector path +} + +// NewPath returns a new Path structure based on supplied root and relative path +func NewPath(root, rel string) *Path { + return &Path{root, rel, formatSelector(rel)} +} + +// newSanitizedPath returns a new sanitized Path structure based on root and relative path +func newSanitizedPath(root, rel string) *Path { + return NewPath(root, sanitizeRawPath(root, rel)) +} + +// Remap remaps a Path to a new relative path, keeping previous selector +func (p *Path) Remap(newRel string) { + p.rel = sanitizeRawPath(p.root, newRel) +} + +// Root returns file's root directory +func (p *Path) Root() string { + return p.root +} + +// Relative returns the relative path +func (p *Path) Relative() string { + return p.rel +} + +// Absolute returns the absolute path +func (p *Path) Absolute() string { + return path.Join(p.root, p.rel) +} + +// Selector returns the formatted selector path +func (p *Path) Selector() string { + return p.sel +} + +// RelativeDir returns the residing dir of the relative path +func (p *Path) RelativeDir() string { + return path.Dir(p.rel) +} + +// SelectorDir returns the residing dir of the selector path +func (p *Path) SelectorDir() string { + return path.Dir(p.sel) +} + +// Dir returns a Path object at the residing dir of the calling object (keeping separate selector intact) +func (p *Path) Dir() *Path { + return &Path{p.root, p.RelativeDir(), p.SelectorDir()} +} + +// JoinRelative returns a string appended to the current relative path +func (p *Path) JoinRelative(newRel string) string { + return path.Join(p.rel, newRel) +} + +// JoinPath appends the supplied string to the Path's relative and selector paths +func (p *Path) JoinPath(toJoin string) *Path { + return &Path{p.root, path.Join(p.rel, toJoin), path.Join(p.sel, toJoin)} +} + +// formatSelector formats a relative path to a valid selector path +func formatSelector(rel string) string { + switch len(rel) { + case 0: + return "/" + case 1: + if rel[0] == '.' { + return "/" + } + return "/" + rel + default: + if rel[0] == '/' { + return rel + } + return "/" + rel + } +} + +// sanitizeRawPath takes a root and relative path, and returns a sanitized relative path +func sanitizeRawPath(root, rel string) string { + // Start by cleaning + rel = path.Clean(rel) + + if path.IsAbs(rel) { + // Absolute path, try trimming root and leading '/' + rel = strings.TrimPrefix(strings.TrimPrefix(rel, root), "/") + } else { + // Relative path, if back dir traversal give them server root + if strings.HasPrefix(rel, "..") { + rel = "" + } + } + + return rel +} + +// sanitizerUserRoot takes a generated user root directory and sanitizes it, returning a bool as to whether it's safe +func sanitizeUserRoot(root string) (string, bool) { + root = path.Clean(root) + if !strings.HasPrefix(root, "/home/") && strings.HasSuffix(root, "/"+userDir) { + return "", false + } + return root, true +} diff --git a/core/regex.go b/core/regex.go new file mode 100644 index 0000000..2701786 --- /dev/null +++ b/core/regex.go @@ -0,0 +1,159 @@ +package core + +import ( + "path" + "regexp" + "strings" +) + +var ( + // cgiDir is a precompiled regex statement to check if a string matches the server's CGI directory + cgiDirRegex *regexp.Regexp + + // WithinCGIDir returns whether a path is within the server's specified CGI scripts directory + WithinCGIDir func(*Path) bool + + // RestrictedPaths is the global slice of restricted paths + restrictedPaths []*regexp.Regexp + + // IsRestrictedPath is the global function to check against restricted paths + IsRestrictedPath func(*Path) bool + + // requestRemaps is the global slice of remapped paths + requestRemaps []*RequestRemap + + // RemapRequest is the global function to remap a request + RemapRequest func(*Request) bool +) + +// PathMapSeparatorStr specifies the separator string to recognise in path mappings +const requestRemapSeparatorStr = " -> " + +// RequestRemap is a structure to hold a remap regex to check against, and a template to apply this transformation onto +type RequestRemap struct { + Regex *regexp.Regexp + Template string +} + +// compileCGIRegex takes a supplied string and returns compiled regular expression +func compileCGIRegex(cgiDir string) *regexp.Regexp { + if path.IsAbs(cgiDir) { + if !strings.HasPrefix(cgiDir, Root) { + SystemLog.Fatal(cgiDirOutsideRootStr) + } + } else { + cgiDir = path.Join(Root, cgiDir) + } + SystemLog.Info(cgiDirStr, cgiDir) + return regexp.MustCompile("(?m)" + cgiDir + "(|/.*)$") +} + +// compileRestrictedPathsRegex turns a string of restricted paths into a slice of compiled regular expressions +func compileRestrictedPathsRegex(restrictions string) []*regexp.Regexp { + regexes := make([]*regexp.Regexp, 0) + + // Split restrictions string by new lines + for _, expr := range strings.Split(restrictions, "\n") { + // Skip empty expressions + if len(expr) == 0 { + continue + } + + // Compile the regular expression + regex, err := regexp.Compile("(?m)" + expr + "$") + if err != nil { + SystemLog.Fatal(pathRestrictRegexCompileFailStr, expr) + } + + // Append compiled regex and log + regexes = append(regexes, regex) + SystemLog.Info(pathRestrictRegexCompiledStr, expr) + } + + return regexes +} + +// compil RequestRemapRegex turns a string of remapped paths into a slice of compiled RequestRemap structures +func compileRequestRemapRegex(remaps string) []*RequestRemap { + requestRemaps := make([]*RequestRemap, 0) + + // Split remaps string by new lines + for _, expr := range strings.Split(remaps, "\n") { + // Skip empty expressions + if len(expr) == 0 { + continue + } + + // Split into alias and remap + split := strings.Split(expr, requestRemapSeparatorStr) + if len(split) != 2 { + SystemLog.Fatal(requestRemapRegexInvalidStr, expr) + } + + // Compile the regular expression + regex, err := regexp.Compile("(?m)" + strings.TrimPrefix(split[0], "/") + "$") + if err != nil { + SystemLog.Fatal(requestRemapRegexCompileFailStr, expr) + } + + // Append RequestRemap and log + requestRemaps = append(requestRemaps, &RequestRemap{regex, strings.TrimPrefix(split[1], "/")}) + SystemLog.Info(requestRemapRegexCompiledStr, expr) + } + + return requestRemaps +} + +// withinCGIDirEnabled returns whether a Path's absolute value matches within the CGI dir +func withinCGIDirEnabled(p *Path) bool { + return cgiDirRegex.MatchString(p.Absolute()) +} + +// withinCGIDirDisabled always returns false, CGI is disabled +func withinCGIDirDisabled(p *Path) bool { + return false +} + +// isRestrictedPathEnabled returns whether a Path's relative value is restricted +func isRestrictedPathEnabled(p *Path) bool { + for _, regex := range restrictedPaths { + if regex.MatchString(p.Relative()) { + return true + } + } + return false +} + +// isRestrictedPathDisabled always returns false, there are no restricted paths +func isRestrictedPathDisabled(p *Path) bool { + return false +} + +// remapRequestEnabled tries to remap a request, returning bool as to success +func remapRequestEnabled(request *Request) bool { + for _, remap := range requestRemaps { + // No match, gotta keep looking + if !remap.Regex.MatchString(request.Path().Selector()) { + continue + } + + // Create new request from template and submatches + raw := make([]byte, 0) + for _, submatches := range remap.Regex.FindAllStringSubmatchIndex(request.Path().Selector(), -1) { + raw = remap.Regex.ExpandString(raw, remap.Template, request.Path().Selector(), submatches) + } + + // Split to new path and paramters again + path, params := splitBy(string(raw), "?") + + // Remap request, log, return + request.Remap(path, params) + return true + } + return false +} + +// remapRequestDisabled always returns false, there are no remapped requests +func remapRequestDisabled(request *Request) bool { + return false +} diff --git a/core/request.go b/core/request.go new file mode 100644 index 0000000..cda42cb --- /dev/null +++ b/core/request.go @@ -0,0 +1,25 @@ +package core + +// Request is a data structure for storing a filesystem path, and params, parsed from a client's request +type Request struct { + p *Path + params string +} + +// Path returns the requests associate Path object +func (r *Request) Path() *Path { + return r.p +} + +// Params returns the request's parameters string +func (r *Request) Params() string { + return r.params +} + +// Remap modifies a request to use new relative path, and accommodate supplied extra parameters +func (r *Request) Remap(rel, params string) { + if len(r.params) > 0 { + r.params = params + "&" + r.params + } + r.p.Remap(rel) +} diff --git a/core/server.go b/core/server.go new file mode 100644 index 0000000..cd9195d --- /dev/null +++ b/core/server.go @@ -0,0 +1,208 @@ +package core + +import ( + "flag" + "fmt" + "os" + "os/signal" + "path" + "strconv" + "strings" + "syscall" + "time" +) + +const ( + // Version holds the current version string + Version = "v0.3-alpha" +) + +var ( + // SigChannel is the global OS signal channel + sigChannel chan os.Signal +) + +// ParseFlagsAndSetup parses necessary core server flags, and sets up the core ready for Start() to be called +func ParseFlagsAndSetup(errorMessageFunc func(ErrorCode) string) { + // Setup numerous temporary flag variables, and store the rest + // directly in their final operating location. Strings are stored + // in `string_constants.go` to allow for later localization + sysLog := flag.String(sysLogFlagStr, "stdout", sysLogDescStr) + accLog := flag.String(accLogFlagStr, "stdout", accLogDescStr) + flag.StringVar(&Root, rootFlagStr, "/var/gopher", rootDescStr) + flag.StringVar(&BindAddr, bindAddrFlagStr, "", bindAddrDescStr) + flag.StringVar(&Hostname, hostnameFlagStr, "localhost", hostnameDescStr) + port := flag.Uint(portFlagStr, 70, portDescStr) + fwdPort := flag.Uint(fwdPortFlagStr, 0, fwdPortDescStr) + flag.DurationVar(&connReadDeadline, readDeadlineFlagStr, time.Duration(time.Second*3), readDeadlineDescStr) + flag.DurationVar(&connWriteDeadline, writeDeadlineFlagStr, time.Duration(time.Second*5), writeDeadlineDescStr) + cReadBuf := flag.Uint(connReadBufFlagStr, 1024, connReadBufDescStr) + cWriteBuf := flag.Uint(connWriteBufFlagStr, 1024, connWriteBufDescStr) + cReadMax := flag.Uint(connReadMaxFlagStr, 4096, connReadMaxDescStr) + fReadBuf := flag.Uint(fileReadBufFlagStr, 1024, fileReadBufDescStr) + flag.DurationVar(&monitorSleepTime, monitorSleepTimeFlagStr, time.Duration(time.Second*1), monitorSleepTimeDescStr) + cacheMax := flag.Float64(cacheFileMaxFlagStr, 1.0, cacheFileMaxDescStr) + cacheSize := flag.Uint(cacheSizeFlagStr, 100, cacheSizeDescStr) + restrictedPathsList := flag.String(restrictPathsFlagStr, "", restrictPathsDescStr) + remapRequestsList := flag.String(remapRequestsFlagStr, "", remapRequestsDescStr) + cgiDir := flag.String(cgiDirFlagStr, "", cgiDirDescStr) + flag.DurationVar(&maxCGIRunTime, maxCGITimeFlagStr, time.Duration(time.Second*3), maxCGITimeDescStr) + safePath := flag.String(safePathFlagStr, "/bin:/usr/bin", safePathDescStr) + httpCompatCGI := flag.Bool(httpCompatCGIFlagStr, false, httpCompatCGIDescStr) + httpPrefixBuf := flag.Uint(httpPrefixBufFlagStr, 1024, httpPrefixBufDescStr) + flag.StringVar(&userDir, userDirFlagStr, "", userDirDescStr) + printVersion := flag.Bool(versionFlagStr, false, versionDescStr) + + // Parse flags! (including any set by outer calling function) + flag.Parse() + + // If version print requested, do so! + if *printVersion { + fmt.Println("Gophor " + Version) + os.Exit(0) + } + + // Setup loggers + SystemLog = setupLogger(*sysLog) + if sysLog == accLog { + AccessLog = SystemLog + } else { + AccessLog = setupLogger(*accLog) + } + + // Check valid values for BindAddr and Hostname + if Hostname == "" { + if BindAddr == "" { + SystemLog.Fatal(hostnameBindAddrEmptyStr) + } + Hostname = BindAddr + } + + // Change to server directory + if osErr := os.Chdir(Root); osErr != nil { + SystemLog.Fatal(chDirErrStr, osErr) + } + SystemLog.Info(chDirStr, Root) + + // Set port info + if *fwdPort == 0 { + fwdPort = port + } + Port = strconv.Itoa(int(*port)) + FwdPort = strconv.Itoa(int(*fwdPort)) + + // Setup listener + var err Error + serverListener, err = newListener(BindAddr, Port) + if err != nil { + SystemLog.Fatal(listenerBeginFailStr, BindAddr, Port, err.Error()) + } + + // Host buffer sizes + connReadBufSize = int(*cReadBuf) + connWriteBufSize = int(*cWriteBuf) + connReadMax = int(*cReadMax) + fileReadBufSize = int(*fReadBuf) + + // FileSystemObject (and related) setup + fileSizeMax = int64(1048576.0 * *cacheMax) // gets megabytes value in bytes + FileSystem = newFileSystemObject(int(*cacheSize)) + + // If no restricted files provided, set to the disabled function. Else, compile and enable + if *restrictedPathsList == "" { + SystemLog.Info(pathRestrictionsDisabledStr) + IsRestrictedPath = isRestrictedPathDisabled + } else { + SystemLog.Info(pathRestrictionsEnabledStr) + restrictedPaths = compileRestrictedPathsRegex(*restrictedPathsList) + IsRestrictedPath = isRestrictedPathEnabled + } + + // If no remapped files provided, set to the disabled function. Else, compile and enable + if *remapRequestsList == "" { + SystemLog.Info(requestRemapDisabledStr) + RemapRequest = remapRequestDisabled + } else { + SystemLog.Info(requestRemapEnabledStr) + requestRemaps = compileRequestRemapRegex(*remapRequestsList) + RemapRequest = remapRequestEnabled + } + + // If no CGI dir supplied, set to disabled function. Else, compile and enable + if *cgiDir == "" { + SystemLog.Info(cgiSupportDisabledStr) + WithinCGIDir = withinCGIDirDisabled + } else { + SystemLog.Info(cgiSupportEnabledStr) + cgiDirRegex = compileCGIRegex(*cgiDir) + cgiEnv = setupInitialCGIEnv(*safePath) + WithinCGIDir = withinCGIDirEnabled + + // Enable HTTP compatible CGI scripts, or not + if *httpCompatCGI { + SystemLog.Info(cgiHTTPCompatEnabledStr, httpPrefixBuf) + ExecuteCGIScript = executeCGIScriptStripHTTP + httpPrefixBufSize = int(*httpPrefixBuf) + } else { + ExecuteCGIScript = executeCGIScriptNoHTTP + } + } + + // If no user dir supplied, set to disabled function. Else, set user dir and enable + if userDir == "" { + SystemLog.Info(userDirDisabledStr) + getRequestPath = getRequestPathUserDirDisabled + } else { + SystemLog.Info(userDirEnabledStr) + getRequestPath = getRequestPathUserDirEnabled + + // Clean the user dir to be safe + userDir = path.Clean(userDir) + if strings.HasPrefix(userDir, "..") { + SystemLog.Fatal(userDirBackTraverseErrStr, userDir) + } else { + SystemLog.Info(userDirStr, userDir) + } + } + + // Set ErrorCode->string function + getExtendedErrorMessage = errorMessageFunc + + // Setup signal channel + sigChannel = make(chan os.Signal) + signal.Notify(sigChannel, syscall.SIGINT, syscall.SIGTERM, syscall.SIGKILL) +} + +// Start begins operation of the server +func Start(serve func(*Client)) { + // Start the FileSystemObject cache freshness monitor + SystemLog.Info(cacheMonitorStartStr, monitorSleepTime) + go FileSystem.StartMonitor() + + // Start the listener + SystemLog.Info(listeningOnStr, BindAddr, Port, Hostname, FwdPort) + go func() { + for { + client, err := serverListener.Accept() + if err != nil { + SystemLog.Error(err.Error()) + } + + // Serve client then close in separate goroutine + go func() { + serve(client) + client.Conn().Close() + }() + } + }() + + // Listen for OS signals and terminate if necessary + listenForOSSignals() +} + +// ListenForOSSignals listens for OS signals and terminates the program if necessary +func listenForOSSignals() { + sig := <-sigChannel + SystemLog.Info(signalReceivedStr, sig) + os.Exit(0) +} diff --git a/core/string_constants.go b/core/string_constants.go new file mode 100644 index 0000000..f8f9bee --- /dev/null +++ b/core/string_constants.go @@ -0,0 +1,151 @@ +package core + +// Core flag string constants +const ( + sysLogFlagStr = "sys-log" + sysLogDescStr = "System log output location ['stdout', 'null', $filename]" + + accLogFlagStr = "acc-log" + accLogDescStr = "Access log output location ['stdout', 'null', $filename]" + + rootFlagStr = "root" + rootDescStr = "Server root directory" + + bindAddrFlagStr = "bind-addr" + bindAddrDescStr = "IP address to bind to" + + hostnameFlagStr = "hostname" + hostnameDescStr = "Server hostname (FQDN)" + + portFlagStr = "port" + portDescStr = "Port to listen on" + + fwdPortFlagStr = "fwd-port" + fwdPortDescStr = "Outward-facing port" + + readDeadlineFlagStr = "read-deadline" + readDeadlineDescStr = "Connection read deadline (timeout)" + + writeDeadlineFlagStr = "write-deadline" + writeDeadlineDescStr = "Connection write deadline (timeout)" + + connReadBufFlagStr = "conn-read-buf" + connReadBufDescStr = "Connection read buffer size (bytes)" + + connWriteBufFlagStr = "conn-write-buf" + connWriteBufDescStr = "Connection write buffer size (bytes)" + + connReadMaxFlagStr = "conn-read-max" + connReadMaxDescStr = "Connection read max (bytes)" + + fileReadBufFlagStr = "file-read-buf" + fileReadBufDescStr = "File read buffer size (bytes)" + + monitorSleepTimeFlagStr = "cache-monitor-freq" + monitorSleepTimeDescStr = "File cache freshness monitor frequency" + + cacheFileMaxFlagStr = "cache-file-max" + cacheFileMaxDescStr = "Max cached file size (megabytes)" + + cacheSizeFlagStr = "cache-size" + cacheSizeDescStr = "File cache size" + + restrictPathsFlagStr = "restrict-paths" + restrictPathsDescStr = "Restrict paths as new-line separated list of regex statements (see documenation)" + + remapRequestsFlagStr = "remap-requests" + remapRequestsDescStr = "Remap requests as new-line separated list of remap statements (see documenation)" + + cgiDirFlagStr = "cgi-dir" + cgiDirDescStr = "CGI scripts directory (empty to disable)" + + maxCGITimeFlagStr = "max-cgi-time" + maxCGITimeDescStr = "Max CGI script execution time" + + safePathFlagStr = "safe-path" + safePathDescStr = "CGI environment safe PATH variable" + + httpCompatCGIFlagStr = "http-compat-cgi" + httpCompatCGIDescStr = "Enable HTTP compatibility for CGI scripts by stripping headers" + + httpPrefixBufFlagStr = "http-prefix-buf" + httpPrefixBufDescStr = "Buffer size used for stripping HTTP headers" + + userDirFlagStr = "user-dir" + userDirDescStr = "User's personal server directory" + + versionFlagStr = "version" + versionDescStr = "Print version string" +) + +// Log string constants +const ( + hostnameBindAddrEmptyStr = "At least one of hostname or bind-addr must be non-empty!" + + chDirStr = "Entered server dir: %s" + chDirErrStr = "Error entering server directory: %s" + + listenerBeginFailStr = "Failed to start listener on %s:%s (%s)" + listeningOnStr = "Listening on: %s:%s (%s:%s)" + + cacheMonitorStartStr = "Starting cache monitor with freq: %s" + + pathRestrictionsEnabledStr = "Path restrictions enabled" + pathRestrictionsDisabledStr = "Path restrictions disabled" + pathRestrictRegexCompileFailStr = "Failed compiling restricted path regex: %s" + pathRestrictRegexCompiledStr = "Compiled restricted path regex: %s" + + requestRemapEnabledStr = "Request remapping enabled" + requestRemapDisabledStr = "Request remapping disabled" + requestRemapRegexInvalidStr = "Invalid request remap regex: %s" + requestRemapRegexCompileFailStr = "Failed compiling request remap regex: %s" + requestRemapRegexCompiledStr = "Compiled path remap regex: %s" + requestRemappedStr = "Remapped request: %s %s" + + cgiSupportEnabledStr = "CGI script support enabled" + cgiSupportDisabledStr = "CGI script support disabled" + cgiDirOutsideRootStr = "CGI directory must not be outside server root!" + cgiDirStr = "CGI directory: %s" + cgiHTTPCompatEnabledStr = "CGI HTTP compatibility enabled, prefix buffer: %d" + cgiExecuteErrStr = "Exit executing: %s [%d]" + + userDirEnabledStr = "User directory support enabled" + userDirDisabledStr = "User directory support disabled" + userDirBackTraverseErrStr = "User directory with back-traversal not supported: %s" + userDirStr = "User directory: %s" + + signalReceivedStr = "Signal received: %v. Shutting down..." + + logOutputErrStr = "Error opening log output %s: %s" + + pgidNotFoundErrStr = "Process unfinished, PGID not found!" + pgidStopErrStr = "Error stopping process group %d: %s" + + connWriteErrStr = "Conn write error" + connReadErrStr = "Conn read error" + connCloseErrStr = "Conn close error" + listenerResolveErrStr = "Listener resolve error" + listenerBeginErrStr = "Listener begin error" + listenerAcceptErrStr = "Listener accept error" + invalidIPErrStr = "Invalid IP" + invalidPortErrStr = "Invalid port" + fileOpenErrStr = "File open error" + fileStatErrStr = "File stat error" + fileReadErrStr = "File read error" + fileTypeErrStr = "Unsupported file type" + directoryReadErrStr = "Directory read error" + restrictedPathErrStr = "Restricted path" + invalidRequestErrStr = "Invalid request" + cgiStartErrStr = "CGI start error" + cgiExitCodeErrStr = "CGI non-zero exit code" + cgiStatus400ErrStr = "CGI status: 400" + cgiStatus401ErrStr = "CGI status: 401" + cgiStatus403ErrStr = "CGI status: 403" + cgiStatus404ErrStr = "CGI status: 404" + cgiStatus408ErrStr = "CGI status: 408" + cgiStatus410ErrStr = "CGI status: 410" + cgiStatus500ErrStr = "CGI status: 500" + cgiStatus501ErrStr = "CGI status: 501" + cgiStatus503ErrStr = "CGI status: 503" + cgiStatusUnknownErrStr = "CGI status: unknown" +) diff --git a/core/url.go b/core/url.go new file mode 100644 index 0000000..f7ce138 --- /dev/null +++ b/core/url.go @@ -0,0 +1,75 @@ +package core + +import ( + "net/url" + "path" + "strings" +) + +var ( + // getRequestPaths points to either of the getRequestPath____ functions + getRequestPath func(string) *Path +) + +// ParseURLEncodedRequest takes a received string and safely parses a request from this +func ParseURLEncodedRequest(received string) (*Request, Error) { + // Check for ASCII control bytes + for i := 0; i < len(received); i++ { + if received[i] < ' ' || received[i] == 0x7f { + return nil, NewError(InvalidRequestErr) + } + } + + // Split into 2 substrings by '?'. URL path and query + rawPath, params := splitBy(received, "?") + + // Unescape path + rawPath, err := url.PathUnescape(rawPath) + if err != nil { + return nil, WrapError(InvalidRequestErr, err) + } + + // Return new request + return &Request{getRequestPath(rawPath), params}, nil +} + +// ParseInternalRequest parses an internal request string based on the current directory +func ParseInternalRequest(p *Path, line string) *Request { + rawPath, params := splitBy(line, "?") + if path.IsAbs(rawPath) { + return &Request{getRequestPath(rawPath), params} + } + return &Request{newSanitizedPath(p.Root(), rawPath), params} +} + +// getRequestPathUserDirEnabled creates a Path object from raw path, converting ~USER to user subdirectory roots, else at server root +func getRequestPathUserDirEnabled(rawPath string) *Path { + if userPath := strings.TrimPrefix(rawPath, "/"); strings.HasPrefix(userPath, "~") { + // We found a user path! Split into the user part, and remaining path + user, remaining := splitBy(userPath, "/") + + // Empty user, we been duped! Return server root + if len(user) <= 1 { + return &Path{Root, "", "/"} + } + + // Get sanitized user root, else return server root + root, ok := sanitizeUserRoot(path.Join("/home", user[1:], userDir)) + if !ok { + return &Path{Root, "", "/"} + } + + // Build new Path + rel := sanitizeRawPath(root, remaining) + sel := "/~" + user[1:] + formatSelector(rel) + return &Path{root, rel, sel} + } + + // Return regular server root + rawPath + return newSanitizedPath(Root, rawPath) +} + +// getRequestPathUserDirDisabled creates a Path object from raw path, always at server root +func getRequestPathUserDirDisabled(rawPath string) *Path { + return newSanitizedPath(Root, rawPath) +} diff --git a/core/util.go b/core/util.go new file mode 100644 index 0000000..3a5f748 --- /dev/null +++ b/core/util.go @@ -0,0 +1,22 @@ +package core + +import ( + "os" + "strings" +) + +// byName and its associated functions provide a quick method of sorting FileInfos by name +type byName []os.FileInfo + +func (s byName) Len() int { return len(s) } +func (s byName) Less(i, j int) bool { return s[i].Name() < s[j].Name() } +func (s byName) Swap(i, j int) { s[i], s[j] = s[j], s[i] } + +// SplitBy takes an input string and a delimiter, returning the resulting two strings from the split (ALWAYS 2) +func splitBy(input, delim string) (string, string) { + split := strings.SplitN(input, delim, 2) + if len(split) == 2 { + return split[0], split[1] + } + return split[0], "" +} diff --git a/error.go b/error.go deleted file mode 100644 index 7a2bf95..0000000 --- a/error.go +++ /dev/null @@ -1,251 +0,0 @@ -package main - -import ( - "fmt" -) - -/* Simple error code type defs */ -type ErrorCode int -type ErrorResponseCode int -const ( - /* Filesystem */ - PathEnumerationErr ErrorCode = iota - IllegalPathErr ErrorCode = iota - FileStatErr ErrorCode = iota - FileOpenErr ErrorCode = iota - FileReadErr ErrorCode = iota - FileTypeErr ErrorCode = iota - DirListErr ErrorCode = iota - - /* Sockets */ - SocketWriteErr ErrorCode = iota - SocketWriteRawErr ErrorCode = iota - - /* Parsing */ - InvalidRequestErr ErrorCode = iota - EmptyItemTypeErr ErrorCode = iota - InvalidGophermapErr ErrorCode = iota - - /* Executing */ - CommandStartErr ErrorCode = iota - CommandExitCodeErr ErrorCode = iota - CgiOutputErr ErrorCode = iota - CgiDisabledErr ErrorCode = iota - RestrictedCommandErr ErrorCode = iota - - /* Wrapping CGI http status codes */ - CgiStatus400Err ErrorCode = iota - CgiStatus401Err ErrorCode = iota - CgiStatus403Err ErrorCode = iota - CgiStatus404Err ErrorCode = iota - CgiStatus408Err ErrorCode = iota - CgiStatus410Err ErrorCode = iota - CgiStatus500Err ErrorCode = iota - CgiStatus501Err ErrorCode = iota - CgiStatus503Err ErrorCode = iota - CgiStatusUnknownErr ErrorCode = iota - - /* Error Response Codes */ - ErrorResponse200 ErrorResponseCode = iota - ErrorResponse400 ErrorResponseCode = iota - ErrorResponse401 ErrorResponseCode = iota - ErrorResponse403 ErrorResponseCode = iota - ErrorResponse404 ErrorResponseCode = iota - ErrorResponse408 ErrorResponseCode = iota - ErrorResponse410 ErrorResponseCode = iota - ErrorResponse500 ErrorResponseCode = iota - ErrorResponse501 ErrorResponseCode = iota - ErrorResponse503 ErrorResponseCode = iota - NoResponse ErrorResponseCode = iota -) - -/* Simple GophorError data structure to wrap another error */ -type GophorError struct { - Code ErrorCode - Err error -} - -/* Convert error code to string */ -func (e *GophorError) Error() string { - var str string - switch e.Code { - case PathEnumerationErr: - str = "path enumeration fail" - case IllegalPathErr: - str = "illegal path requested" - case FileStatErr: - str = "file stat fail" - case FileOpenErr: - str = "file open fail" - case FileReadErr: - str = "file read fail" - case FileTypeErr: - str = "invalid file type" - case DirListErr: - str = "directory read fail" - - case SocketWriteErr: - str = "socket write error" - case SocketWriteRawErr: - str = "socket write readFrom error" - - case InvalidRequestErr: - str = "invalid request data" - case InvalidGophermapErr: - str = "invalid gophermap" - - case CommandStartErr: - str = "command start fail" - case CgiOutputErr: - str = "cgi output format error" - case CommandExitCodeErr: - str = "command exit code non-zero" - case CgiDisabledErr: - str = "ignoring /cgi-bin request, CGI disabled" - case RestrictedCommandErr: - str = "command use restricted" - - case CgiStatus400Err: - str = "CGI script error status 400" - case CgiStatus401Err: - str = "CGI script error status 401" - case CgiStatus403Err: - str = "CGI script error status 403" - case CgiStatus404Err: - str = "CGI script error status 404" - case CgiStatus408Err: - str = "CGI script error status 408" - case CgiStatus410Err: - str = "CGI script error status 410" - case CgiStatus500Err: - str = "CGI script error status 500" - case CgiStatus501Err: - str = "CGI script error status 501" - case CgiStatus503Err: - str = "CGI script error status 503" - case CgiStatusUnknownErr: - str = "CGI script error unknown status code" - - default: - str = "Unknown" - } - - if e.Err != nil { - return fmt.Sprintf("%s (%s)", str, e.Err.Error()) - } else { - return fmt.Sprintf("%s", str) - } -} - -/* Convert a gophor error code to appropriate error response code */ -func gophorErrorToResponseCode(code ErrorCode) ErrorResponseCode { - switch code { - case PathEnumerationErr: - return ErrorResponse400 - case IllegalPathErr: - return ErrorResponse403 - case FileStatErr: - return ErrorResponse404 - case FileOpenErr: - return ErrorResponse404 - case FileReadErr: - return ErrorResponse404 - case FileTypeErr: - /* If wrong file type, just assume file not there */ - return ErrorResponse404 - case DirListErr: - return ErrorResponse404 - - /* These are errors _while_ sending, no point trying to send error */ - case SocketWriteErr: - return NoResponse - case SocketWriteRawErr: - return NoResponse - - case InvalidRequestErr: - return ErrorResponse400 - case InvalidGophermapErr: - return ErrorResponse500 - - case CommandStartErr: - return ErrorResponse500 - case CommandExitCodeErr: - return ErrorResponse500 - case CgiOutputErr: - return ErrorResponse500 - case CgiDisabledErr: - return ErrorResponse404 - case RestrictedCommandErr: - return ErrorResponse500 - - case CgiStatus400Err: - return ErrorResponse400 - case CgiStatus401Err: - return ErrorResponse401 - case CgiStatus403Err: - return ErrorResponse403 - case CgiStatus404Err: - return ErrorResponse404 - case CgiStatus408Err: - return ErrorResponse408 - case CgiStatus410Err: - return ErrorResponse410 - case CgiStatus500Err: - return ErrorResponse500 - case CgiStatus501Err: - return ErrorResponse501 - case CgiStatus503Err: - return ErrorResponse503 - case CgiStatusUnknownErr: - return ErrorResponse500 - - default: - return ErrorResponse503 - } -} - -/* Generates gopher protocol compatible error response from our code */ -func generateGopherErrorResponseFromCode(code ErrorCode) []byte { - responseCode := gophorErrorToResponseCode(code) - if responseCode == NoResponse { - return nil - } - return generateGopherErrorResponse(responseCode) -} - -/* Generates gopher protocol compatible error response for response code */ -func generateGopherErrorResponse(code ErrorResponseCode) []byte { - return buildErrorLine(code.String()) -} - -/* Error response code to string */ -func (e ErrorResponseCode) String() string { - switch e { - case ErrorResponse200: - /* Should not have reached here */ - Config.SysLog.Fatal("", "Passed error response 200 to error handler, SHOULD NOT HAVE DONE THIS\n") - return "" - case ErrorResponse400: - return "400 Bad Request" - case ErrorResponse401: - return "401 Unauthorised" - case ErrorResponse403: - return "403 Forbidden" - case ErrorResponse404: - return "404 Not Found" - case ErrorResponse408: - return "408 Request Time-out" - case ErrorResponse410: - return "410 Gone" - case ErrorResponse500: - return "500 Internal Server Error" - case ErrorResponse501: - return "501 Not Implemented" - case ErrorResponse503: - return "503 Service Unavailable" - default: - /* Should not have reached here */ - Config.SysLog.Fatal("", "Unhandled ErrorResponseCode type\n") - return "" - } -} diff --git a/exec.go b/exec.go deleted file mode 100644 index 5c0cdfb..0000000 --- a/exec.go +++ /dev/null @@ -1,160 +0,0 @@ -package main - -import ( - "io" - "os/exec" - "syscall" - "strconv" - "time" -) - -/* Setup initial (i.e. constant) gophermap / command environment variables */ -func setupExecEnviron(path string) []string { - return []string { - envKeyValue("PATH", path), - } -} - -/* Setup initial (i.e. constant) CGI environment variables */ -func setupInitialCgiEnviron(path, charset string) []string { - return []string{ - /* RFC 3875 standard */ - envKeyValue("GATEWAY_INTERFACE", "CGI/1.1"), /* MUST be set to the dialect of CGI being used by the server */ - envKeyValue("SERVER_SOFTWARE", "gophor/"+GophorVersion), /* MUST be set to name and version of server software serving this request */ - envKeyValue("SERVER_PROTOCOL", "gopher"), /* MUST be set to name and version of application protocol used for this request */ - envKeyValue("CONTENT_LENGTH", "0"), /* Contains size of message-body attached (always 0 so we set here) */ - envKeyValue("REQUEST_METHOD", "GET"), /* MUST be set to method by which script should process request. Always GET */ - - /* Non-standard */ - envKeyValue("PATH", path), - envKeyValue("COLUMNS", strconv.Itoa(Config.PageWidth)), - envKeyValue("GOPHER_CHARSET", charset), - } -} - -/* Generate CGI environment */ -func generateCgiEnvironment(responder *Responder) []string { - /* Get initial CgiEnv variables */ - env := Config.CgiEnv - - env = append(env, envKeyValue("SERVER_NAME", responder.Host.Name())) /* MUST be set to name of server host client is connecting to */ - env = append(env, envKeyValue("SERVER_PORT", responder.Host.Port())) /* MUST be set to the server port that client is connecting to */ - env = append(env, envKeyValue("REMOTE_ADDR", responder.Client.Ip())) /* Remote client addr, MUST be set */ - env = append(env, envKeyValue("QUERY_STRING", responder.Request.Parameters)) /* URL encoded search or parameter string, MUST be set even if empty */ - env = append(env, envKeyValue("SCRIPT_NAME", "/"+responder.Request.Path.Relative())) /* URI path (not URL encoded) which could identify the CGI script (rather than script's output) */ - env = append(env, envKeyValue("SCRIPT_FILENAME", responder.Request.Path.Absolute())) /* Basically SCRIPT_NAME absolute path */ - env = append(env, envKeyValue("SELECTOR", responder.Request.Path.Selector())) - env = append(env, envKeyValue("DOCUMENT_ROOT", responder.Request.Path.RootDir())) - env = append(env, envKeyValue("REQUEST_URI", "/"+responder.Request.Path.Relative()+responder.Request.Parameters)) - - return env -} - -/* Execute a CGI script (pointer to correct function) */ -var executeCgi func(*Responder) *GophorError - -/* Execute CGI script and serve as-is */ -func executeCgiNoHttp(responder *Responder) *GophorError { - return execute(responder.Conn, generateCgiEnvironment(responder), responder.Request.Path.Absolute()) -} - -/* Execute CGI script and strip HTTP headers */ -func executeCgiStripHttp(responder *Responder) *GophorError { - /* HTTP header stripping writer that also parses HTTP status codes */ - httpStripWriter := NewHttpStripWriter(responder.Conn) - - /* Execute the CGI script using the new httpStripWriter */ - gophorErr := execute(httpStripWriter, generateCgiEnvironment(responder), responder.Request.Path.Absolute()) - - /* httpStripWriter's error takes priority as it might have parsed the status code */ - cgiStatusErr := httpStripWriter.FinishUp() - if cgiStatusErr != nil { - return cgiStatusErr - } else { - return gophorErr - } -} - -/* Execute any file (though only allowed are gophermaps) */ -func executeFile(responder *Responder) *GophorError { - return execute(responder.Conn, Config.Env, responder.Request.Path.Absolute()) -} - -/* Execute a supplied path with arguments and environment, to writer */ -func execute(writer io.Writer, env []string, path string) *GophorError { - /* If CGI disbabled, just return error */ - if !Config.CgiEnabled { - return &GophorError{ CgiDisabledErr, nil } - } - - /* Setup command */ - cmd := exec.Command(path) - - /* Set new proccess group id */ - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - - /* Setup cmd env */ - cmd.Env = env - - /* Setup out buffer */ - cmd.Stdout = writer - - /* Start executing! */ - err := cmd.Start() - if err != nil { - return &GophorError{ CommandStartErr, err } - } - - /* Setup timer goroutine to kill cmd after x time */ - go func() { - time.Sleep(Config.MaxExecRunTime) - - if cmd.ProcessState != nil { - /* We've already finished */ - return - } - - /* Get process group id */ - pgid, err := syscall.Getpgid(cmd.Process.Pid) - if err != nil { - Config.SysLog.Fatal("", "Process unfinished, PGID not found!\n") - } - - /* Kill process group! */ - err = syscall.Kill(-pgid, syscall.SIGTERM) - if err != nil { - Config.SysLog.Fatal("", "Error stopping process group %d: %s\n", pgid, err.Error()) - } - }() - - /* Wait for command to finish, get exit code */ - err = cmd.Wait() - exitCode := 0 - if err != nil { - /* Error, try to get exit code */ - exitError, ok := err.(*exec.ExitError) - if ok { - waitStatus := exitError.Sys().(syscall.WaitStatus) - exitCode = waitStatus.ExitStatus() - } else { - exitCode = 1 - } - } else { - /* No error! Get exit code direct from command */ - waitStatus := cmd.ProcessState.Sys().(syscall.WaitStatus) - exitCode = waitStatus.ExitStatus() - } - - if exitCode != 0 { - /* If non-zero exit code return error */ - Config.SysLog.Error("", "Error executing: %s\n", path) - return &GophorError{ CommandExitCodeErr, err } - } else { - return nil - } -} - -/* Just neatens creating an environment KEY=VALUE string */ -func envKeyValue(key, value string) string { - return key+"="+value -} diff --git a/filecontents.go b/filecontents.go deleted file mode 100644 index 1f94674..0000000 --- a/filecontents.go +++ /dev/null @@ -1,353 +0,0 @@ -package main - -import ( - "bytes" - "bufio" - "os" -) - -type FileContents interface { - /* Interface that provides an adaptable implementation - * for holding onto some level of information about the - * contents of a file. - */ - Render(*Responder) *GophorError - Load() *GophorError - Clear() -} - -type GeneratedFileContents struct { - /* Super simple, holds onto a slice of bytes */ - - Contents []byte -} - -func (fc *GeneratedFileContents) Render(responder *Responder) *GophorError { - return responder.WriteData(fc.Contents) -} - -func (fc *GeneratedFileContents) Load() *GophorError { - /* do nothing */ - return nil -} - -func (fc *GeneratedFileContents) Clear() { - /* do nothing */ -} - -type RegularFileContents struct { - /* Simple implemention that holds onto a RequestPath - * and slice containing cache'd content - */ - - Path *RequestPath - Contents []byte -} - -func (fc *RegularFileContents) Render(responder *Responder) *GophorError { - return responder.WriteData(fc.Contents) -} - -func (fc *RegularFileContents) Load() *GophorError { - /* Load the file into memory */ - var gophorErr *GophorError - fc.Contents, gophorErr = bufferedRead(fc.Path.Absolute()) - return gophorErr -} - -func (fc *RegularFileContents) Clear() { - fc.Contents = nil -} - -type GophermapContents struct { - /* Holds onto a RequestPath and slice containing individually - * renderable sections of the gophermap. - */ - - Request *Request - Sections []GophermapSection -} - -func (gc *GophermapContents) Render(responder *Responder) *GophorError { - /* Render and send each of the gophermap sections */ - var gophorErr *GophorError - for _, line := range gc.Sections { - gophorErr = line.Render(responder) - if gophorErr != nil { - Config.SysLog.Error("", "Error executing gophermap contents: %s\n", gophorErr.Error()) - return &GophorError{ InvalidGophermapErr, gophorErr } - } - } - - /* End on footer text (including lastline) */ - return responder.WriteData(Config.FooterText) -} - -func (gc *GophermapContents) Load() *GophorError { - /* Load the gophermap into memory as gophermap sections */ - var gophorErr *GophorError - gc.Sections, gophorErr = readGophermap(gc.Request) - if gophorErr != nil { - return &GophorError{ InvalidGophermapErr, gophorErr } - } else { - return nil - } -} - -func (gc *GophermapContents) Clear() { - gc.Sections = nil -} - -type GophermapSection interface { - /* Interface for storing differring types of gophermap - * sections to render when necessary - */ - - Render(*Responder) *GophorError -} - -type GophermapTextSection struct { - Contents []byte -} - -func (s *GophermapTextSection) Render(responder *Responder) *GophorError { - return responder.WriteData(replaceStrings(string(s.Contents), responder.Host)) -} - -type GophermapDirectorySection struct { - /* Holds onto a directory path, and a list of files - * to hide from the client when rendering. - */ - - Request *Request - Hidden map[string]bool -} - -func (g *GophermapDirectorySection) Render(responder *Responder) *GophorError { - /* Create new responder from supplied and using stored path */ - return listDir(responder.CloneWithRequest(g.Request), g.Hidden) -} - -type GophermapFileSection struct { - /* Holds onto a file path to be read and rendered when requested */ - Request *Request -} - -func (g *GophermapFileSection) Render(responder *Responder) *GophorError { - fileContents, gophorErr := readIntoGophermap(g.Request.Path.Absolute()) - if gophorErr != nil { - return gophorErr - } - return responder.WriteData(fileContents) -} - -type GophermapSubmapSection struct { - /* Holds onto a gophermap path to be read and rendered when requested */ - Request *Request -} - -func (g *GophermapSubmapSection) Render(responder *Responder) *GophorError { - /* Load the gophermap into memory as gophermap sections */ - sections, gophorErr := readGophermap(g.Request) - if gophorErr != nil { - return gophorErr - } - - /* Render and send each of the gophermap sections */ - for _, line := range sections { - gophorErr = line.Render(responder) - if gophorErr != nil { - Config.SysLog.Error("", "Error executing gophermap contents: %s\n", gophorErr.Error()) - } - } - - return nil -} - -type GophermapExecCgiSection struct { - /* Holds onto a request with CGI script path and supplied parameters */ - Request *Request -} - -func (g *GophermapExecCgiSection) Render(responder *Responder) *GophorError { - /* Create new filesystem request from mixture of stored + supplied */ - return executeCgi(responder.CloneWithRequest(g.Request)) -} - -type GophermapExecFileSection struct { - /* Holds onto a request with executable file path and supplied arguments */ - Request *Request -} - -func (g *GophermapExecFileSection) Render(responder *Responder) *GophorError { - /* Create new responder from supplied and using stored path */ - return executeFile(responder.CloneWithRequest(g.Request)) -} - -/* Read and parse a gophermap into separately cacheable and renderable GophermapSection */ -func readGophermap(request *Request) ([]GophermapSection, *GophorError) { - /* Create return slice */ - sections := make([]GophermapSection, 0) - - /* Create hidden files map now in case dir listing requested */ - hidden := map[string]bool{ - request.Path.Relative(): true, /* Ignore current gophermap */ - CgiBinDirStr: true, /* Ignore cgi-bin if found */ - } - - /* Keep track of whether we've already come across a title line (only 1 allowed!) */ - titleAlready := false - - /* Error setting within nested function below */ - var returnErr *GophorError - - /* Perform buffered scan with our supplied splitter and iterators */ - gophorErr := bufferedScan(request.Path.Absolute(), - func(scanner *bufio.Scanner) bool { - line := scanner.Text() - - /* Parse the line item type and handle */ - lineType := parseLineType(line) - switch lineType { - case TypeInfoNotStated: - /* Append TypeInfo to the beginning of line */ - sections = append(sections, &GophermapTextSection{ buildInfoLine(line) }) - - case TypeTitle: - /* Reformat title line to send as info line with appropriate selector */ - if !titleAlready { - sections = append(sections, &GophermapTextSection{ buildLine(TypeInfo, line[1:], "TITLE", NullHost, NullPort) }) - titleAlready = true - } - - case TypeComment: - /* We ignore this line */ - break - - case TypeHiddenFile: - /* Add to hidden files map */ - hidden[request.Path.JoinRel(line[1:])] = true - - case TypeSubGophermap: - /* Parse new RequestPath and parameters */ - subRequest, gophorErr := parseLineRequestString(request.Path, line[1:]) - if gophorErr != nil { - /* Failed parsing line request string, set returnErr and request finish */ - returnErr = gophorErr - return true - } else if subRequest.Path.Relative() == "" || subRequest.Path.Relative() == request.Path.Relative() { - /* Failed parsing line request string, or we've been supplied same gophermap, and recursion is - * recursion is recursion is bad kids! Set return error and request finish. - */ - returnErr = &GophorError{ InvalidRequestErr, nil } - return true - } - - /* Perform file stat */ - stat, err := os.Stat(subRequest.Path.Absolute()) - if (err != nil) || (stat.Mode() & os.ModeDir != 0) { - /* File read error or is directory */ - returnErr = &GophorError{ FileStatErr, err } - return true - } - - /* Check if we've been supplied subgophermap or regular file */ - if isGophermap(subRequest.Path.Relative()) { - /* If executable, store as GophermapExecFileSection, else GophermapSubmapSection */ - if stat.Mode().Perm() & 0100 != 0 { - sections = append(sections, &GophermapExecFileSection { subRequest }) - } else { - sections = append(sections, &GophermapSubmapSection{ subRequest }) - } - } else { - /* If stored in cgi-bin store as GophermapExecCgiSection, else GophermapFileSection */ - if withinCgiBin(subRequest.Path.Relative()) { - sections = append(sections, &GophermapExecCgiSection{ subRequest }) - } else { - sections = append(sections, &GophermapFileSection{ subRequest }) - } - } - - case TypeEnd: - /* Lastline, break out at end of loop. GophermapContents.Render() will - * append a LastLine string so we don't have to worry about that here. - */ - return false - - case TypeEndBeginList: - /* Append GophermapDirectorySection object then break, as with TypeEnd. */ - dirRequest := &Request{ NewRequestPath(request.Path.RootDir(), request.Path.TrimRelSuffix(GophermapFileStr)), "" } - sections = append(sections, &GophermapDirectorySection{ dirRequest, hidden }) - return false - - default: - /* Default is appending to sections slice as GopherMapTextSection */ - sections = append(sections, &GophermapTextSection{ []byte(line+DOSLineEnd) }) - } - - return true - }, - ) - - /* Check the bufferedScan didn't exit with error */ - if gophorErr != nil { - return nil, gophorErr - } else if returnErr != nil { - return nil, returnErr - } - - return sections, nil -} - -/* Read a text file into a gophermap as text sections */ -func readIntoGophermap(path string) ([]byte, *GophorError) { - /* Create return slice */ - fileContents := make([]byte, 0) - - /* Perform buffered scan with our supplied iterator */ - gophorErr := bufferedScan(path, - func(scanner *bufio.Scanner) bool { - line := scanner.Text() - - if line == "" { - fileContents = append(fileContents, buildInfoLine("")...) - return true - } - - /* Replace the newline characters */ - line = replaceNewLines(line) - - /* Iterate through line string, reflowing to new line - * until all lines < PageWidth - */ - for len(line) > 0 { - length := minWidth(len(line)) - fileContents = append(fileContents, buildInfoLine(line[:length])...) - line = line[length:] - } - - return true - }, - ) - - /* Check the bufferedScan didn't exit with error */ - if gophorErr != nil { - return nil, gophorErr - } - - /* Check final output ends on a newline */ - if !bytes.HasSuffix(fileContents, []byte(DOSLineEnd)) { - fileContents = append(fileContents, []byte(DOSLineEnd)...) - } - - return fileContents, nil -} - -/* Return minimum width out of PageWidth and W */ -func minWidth(w int) int { - if w <= Config.PageWidth { - return w - } else { - return Config.PageWidth - } -} diff --git a/filesystem.go b/filesystem.go deleted file mode 100644 index 8ca896f..0000000 --- a/filesystem.go +++ /dev/null @@ -1,351 +0,0 @@ -package main - -import ( - "os" - "sync" - "time" - "regexp" -) - -const ( - /* Help converting file size stat to supplied size in megabytes */ - BytesInMegaByte = 1048576.0 - - /* Filename constants */ - CgiBinDirStr = "cgi-bin" - GophermapFileStr = "gophermap" -) - -type FileSystem struct { - /* Holds and helps manage our file cache, as well as managing - * access and responses to requests submitted a worker instance. - */ - - CacheMap *FixedMap - CacheMutex sync.RWMutex - CacheFileMax int64 - Remaps []*FileRemap - Restricted []*regexp.Regexp -} - -func (fs *FileSystem) Init(size int, fileSizeMax float64) { - fs.CacheMap = NewFixedMap(size) - fs.CacheMutex = sync.RWMutex{} - fs.CacheFileMax = int64(BytesInMegaByte * fileSizeMax) - /* .Remaps and .Restricted are handled within gopher.go */ -} - -func (fs *FileSystem) IsRestricted(path string) bool { - for _, regex := range fs.Restricted { - if regex.MatchString(path) { - return true - } - } - return false -} - -func (fs *FileSystem) RemapRequestPath(requestPath *RequestPath) (*RequestPath, bool) { - for _, remap := range fs.Remaps { - /* No match :( keep lookin */ - if !remap.Regex.MatchString(requestPath.Relative()) { - continue - } - - /* Create new path from template and submatches */ - newPath := make([]byte, 0) - for _, submatches := range remap.Regex.FindAllStringSubmatchIndex(requestPath.Relative(), -1) { - newPath = remap.Regex.ExpandString(newPath, remap.Template, requestPath.Relative(), submatches) - } - - /* Ignore empty replacement path */ - if len(newPath) == 0 { - continue - } - - /* Set this new path to the _actual_ path */ - return requestPath.RemapPath(string(newPath)), true - } - - return nil, false -} - -func (fs *FileSystem) HandleRequest(responder *Responder) *GophorError { - /* Check if restricted file */ - if fs.IsRestricted(responder.Request.Path.Relative()) { - return &GophorError{ IllegalPathErr, nil } - } - - /* Try remap according to supplied regex */ - remap, doneRemap := fs.RemapRequestPath(responder.Request.Path) - - var err error - var stat os.FileInfo - if doneRemap { - /* Try get the remapped path */ - stat, err = os.Stat(remap.Absolute()) - if err == nil { - /* Remapped path exists, set this! */ - responder.Request.Path = remap - } else { - /* Last ditch effort to grab generated file */ - return fs.FetchGeneratedFile(responder, err) - } - } else { - /* Just get regular supplied request path */ - stat, err = os.Stat(responder.Request.Path.Absolute()) - if err != nil { - /* Last ditch effort to grab generated file */ - return fs.FetchGeneratedFile(responder, err) - } - } - - switch { - /* Directory */ - case stat.Mode() & os.ModeDir != 0: - /* Ignore anything under cgi-bin directory */ - if withinCgiBin(responder.Request.Path.Relative()) { - return &GophorError{ IllegalPathErr, nil } - } - - /* Check Gophermap exists */ - gophermapPath := NewRequestPath(responder.Request.Path.RootDir(), responder.Request.Path.JoinRel(GophermapFileStr)) - stat, err = os.Stat(gophermapPath.Absolute()) - - if err == nil { - /* Gophermap exists! If executable try return executed contents, else serve as regular gophermap. */ - gophermapRequest := &Request{ gophermapPath, responder.Request.Parameters } - responder.Request = gophermapRequest - - if stat.Mode().Perm() & 0100 != 0 { - return executeFile(responder) - } else { - return fs.FetchFile(responder) - } - } else { - /* No gophermap, serve directory listing */ - return listDirAsGophermap(responder, map[string]bool{ gophermapPath.Relative(): true, CgiBinDirStr: true }) - } - - /* Regular file */ - case stat.Mode() & os.ModeType == 0: - /* If cgi-bin, try return executed contents. Else, fetch regular file */ - if responder.Request.Path.HasRelPrefix(CgiBinDirStr) { - return executeCgi(responder) - } else { - return fs.FetchFile(responder) - } - - /* Unsupported type */ - default: - return &GophorError{ FileTypeErr, nil } - } -} - -func (fs *FileSystem) FetchGeneratedFile(responder *Responder, err error) *GophorError { - fs.CacheMutex.RLock() - file := fs.CacheMap.Get(responder.Request.Path.Absolute()) - if file == nil { - /* Generated file at path not in cache map either, return */ - fs.CacheMutex.RUnlock() - return &GophorError{ FileStatErr, err } - } - - /* It's there! Get contents! */ - file.Mutex.RLock() - gophorErr := file.WriteContents(responder) - file.Mutex.RUnlock() - - fs.CacheMutex.RUnlock() - return gophorErr -} - -func (fs *FileSystem) FetchFile(responder *Responder) *GophorError { - /* Get cache map read lock then check if file in cache map */ - fs.CacheMutex.RLock() - file := fs.CacheMap.Get(responder.Request.Path.Absolute()) - - if file != nil { - /* File in cache -- before doing anything get file read lock */ - file.Mutex.RLock() - - /* Check file is marked as fresh */ - if !file.Fresh { - /* File not fresh! Swap file read for write-lock */ - file.Mutex.RUnlock() - file.Mutex.Lock() - - /* Reload file contents from disk */ - gophorErr := file.CacheContents() - if gophorErr != nil { - /* Error loading contents, unlock all mutex then return error */ - file.Mutex.Unlock() - fs.CacheMutex.RUnlock() - return gophorErr - } - - /* Updated! Swap back file write for read lock */ - file.Mutex.Unlock() - file.Mutex.RLock() - } - } else { - /* Open file here, to check it exists, ready for file stat - * and in case file is too big we pass it as a raw response - */ - fd, err := os.Open(responder.Request.Path.Absolute()) - if err != nil { - /* Error stat'ing file, unlock read mutex then return error */ - fs.CacheMutex.RUnlock() - return &GophorError{ FileOpenErr, err } - } - - /* We need a doctor, stat! */ - stat, err := fd.Stat() - if err != nil { - /* Error stat'ing file, unlock read mutext then return */ - fs.CacheMutex.RUnlock() - return &GophorError{ FileStatErr, err } - } - - /* Compare file size (in MB) to CacheFileSizeMax. If larger, just send file raw */ - if stat.Size() > fs.CacheFileMax { - /* Unlock the read mutex, we don't need it where we're going... returning, we're returning. */ - fs.CacheMutex.RUnlock() - return responder.WriteRaw(fd) - } - - /* Create new file contents */ - var contents FileContents - if isGophermap(responder.Request.Path.Relative()) { - contents = &GophermapContents{ responder.Request, nil } - } else { - contents = &RegularFileContents{ responder.Request.Path, nil } - } - - /* Create new file wrapper around contents */ - file = &File{ contents, sync.RWMutex{}, true, time.Now().UnixNano() } - - /* File isn't in cache yet so no need to get file lock mutex */ - gophorErr := file.CacheContents() - if gophorErr != nil { - /* Error loading contents, unlock read mutex then return error */ - fs.CacheMutex.RUnlock() - return gophorErr - } - - /* File not in cache -- Swap cache map read for write lock. */ - fs.CacheMutex.RUnlock() - fs.CacheMutex.Lock() - - /* Put file in the FixedMap */ - fs.CacheMap.Put(responder.Request.Path.Absolute(), file) - - /* Before unlocking cache mutex, lock file read for upcoming call to .Contents() */ - file.Mutex.RLock() - - /* Swap cache lock back to read */ - fs.CacheMutex.Unlock() - fs.CacheMutex.RLock() - } - - /* Write file contents via responder */ - gophorErr := file.WriteContents(responder) - file.Mutex.RUnlock() - - /* Finally we can unlock the cache map read lock, we are done :) */ - fs.CacheMutex.RUnlock() - - return gophorErr -} - -type File struct { - /* Wraps around the cached contents of a file - * helping with management. - */ - - Content FileContents - Mutex sync.RWMutex - Fresh bool - LastRefresh int64 -} - -func (f *File) WriteContents(responder *Responder) *GophorError { - return f.Content.Render(responder) -} - -func (f *File) CacheContents() *GophorError { - /* Clear current file contents */ - f.Content.Clear() - - /* Reload the file */ - gophorErr := f.Content.Load() - if gophorErr != nil { - return gophorErr - } - - /* Update lastRefresh, set fresh, unset deletion (not likely set) */ - f.LastRefresh = time.Now().UnixNano() - f.Fresh = true - return nil -} - -/* Start the file monitor! */ -func startFileMonitor(sleepTime time.Duration) { - go func() { - for { - /* Sleep so we don't take up all the precious CPU time :) */ - time.Sleep(sleepTime) - - /* Check global file cache freshness */ - checkCacheFreshness() - } - - /* We shouldn't have reached here */ - Config.SysLog.Fatal("", "FileCache monitor escaped run loop!\n") - }() -} - -/* Check file cache for freshness, deleting files not-on disk */ -func checkCacheFreshness() { - /* Before anything, get cache write lock (in case we have to delete) */ - Config.FileSystem.CacheMutex.Lock() - - /* Iterate through paths in cache map to query file last modified times */ - for path := range Config.FileSystem.CacheMap.Map { - /* Get file pointer, no need for lock as we have write lock */ - file := Config.FileSystem.CacheMap.Get(path) - - /* If this is a generated file, we skip */ - if isGeneratedType(file) { - continue - } - - /* Check file still exists on disk, delete and continue if not */ - stat, err := os.Stat(path) - if err != nil { - Config.SysLog.Error("", "Failed to stat file in cache: %s\n", path) - Config.FileSystem.CacheMap.Remove(path) - continue - } - - /* Get file's last modified time */ - timeModified := stat.ModTime().UnixNano() - - /* If the file is marked as fresh, but file on disk is newer, mark as unfresh */ - if file.Fresh && file.LastRefresh < timeModified { - file.Fresh = false - } - } - - /* Done! We can release cache read lock */ - Config.FileSystem.CacheMutex.Unlock() -} - -/* Just a helper function to neaten-up checking if file contents is of generated type */ -func isGeneratedType(file *File) bool { - switch file.Content.(type) { - case *GeneratedFileContents: - return true - default: - return false - } -} diff --git a/filesystem_read.go b/filesystem_read.go deleted file mode 100644 index 311dbd0..0000000 --- a/filesystem_read.go +++ /dev/null @@ -1,202 +0,0 @@ -package main - -import ( - "os" - "bytes" - "io" - "sort" - "bufio" -) - -const ( - FileReadBufSize = 1024 -) - -/* Perform simple buffered read on a file at path */ -func bufferedRead(path string) ([]byte, *GophorError) { - /* Open file */ - fd, err := os.Open(path) - if err != nil { - return nil, &GophorError{ FileOpenErr, err } - } - defer fd.Close() - - /* Setup buffers */ - var count int - contents := make([]byte, 0) - buf := make([]byte, FileReadBufSize) - - /* Setup reader */ - reader := bufio.NewReader(fd) - - /* Read through buffer until error or null bytes! */ - for { - count, err = reader.Read(buf) - if err != nil { - if err == io.EOF { - break - } - - return nil, &GophorError{ FileReadErr, err } - } - - contents = append(contents, buf[:count]...) - - if count < FileReadBufSize { - break - } - } - - return contents, nil -} - -/* Perform buffered read on file at path, then scan through with supplied iterator func */ -func bufferedScan(path string, scanIterator func(*bufio.Scanner) bool) *GophorError { - /* First, read raw file contents */ - contents, gophorErr := bufferedRead(path) - if gophorErr != nil { - return gophorErr - } - - /* Create reader and scanner from this */ - reader := bytes.NewReader(contents) - scanner := bufio.NewScanner(reader) - - /* If contains DOS line-endings, split by DOS! Else, split by Unix */ - if bytes.Contains(contents, []byte(DOSLineEnd)) { - scanner.Split(dosLineEndSplitter) - } else { - scanner.Split(unixLineEndSplitter) - } - - /* Scan through file contents using supplied iterator */ - for scanner.Scan() && scanIterator(scanner) {} - - /* Check scanner finished cleanly */ - if scanner.Err() != nil { - return &GophorError{ FileReadErr, scanner.Err() } - } - - return nil -} - -/* Split on DOS line end */ -func dosLineEndSplitter(data []byte, atEOF bool) (advance int, token []byte, err error) { - if atEOF && len(data) == 0 { - /* At EOF, no more data */ - return 0, nil, nil - } - - if i := bytes.Index(data, []byte("\r\n")); i >= 0 { - /* We have a full new-line terminate line */ - return i+2, data[:i], nil - } - - /* Request more data */ - return 0, nil, nil -} - -/* Split on unix line end */ -func unixLineEndSplitter(data []byte, atEOF bool) (advance int, token []byte, err error) { - if atEOF && len(data) == 0 { - /* At EOF, no more data */ - return 0, nil, nil - } - - if i := bytes.Index(data, []byte("\n")); i >= 0 { - /* We have a full new-line terminate line */ - return i+1, data[:i], nil - } - - /* Request more data */ - return 0, nil, nil -} - -/* List the files in directory, hiding those requested, including title and footer */ -func listDirAsGophermap(responder *Responder, hidden map[string]bool) *GophorError { - /* Write title */ - gophorErr := responder.WriteData(append(buildLine(TypeInfo, "[ "+responder.Host.Name()+responder.Request.Path.Selector()+" ]", "TITLE", NullHost, NullPort), buildInfoLine("")...)) - if gophorErr != nil { - return gophorErr - } - - /* Writer a 'back' entry. GoLang Readdir() seems to miss this */ - gophorErr = responder.WriteData(buildLine(TypeDirectory, "..", responder.Request.Path.JoinSelector(".."), responder.Host.Name(), responder.Host.Port())) - if gophorErr != nil { - return gophorErr - } - - /* Write the actual directory entry */ - gophorErr = listDir(responder, hidden) - if gophorErr != nil { - return gophorErr - } - - /* Finally write footer */ - return responder.WriteData(Config.FooterText) -} - -/* List the files in a directory, hiding those requested */ -func listDir(responder *Responder, hidden map[string]bool) *GophorError { - /* Open directory file descriptor */ - fd, err := os.Open(responder.Request.Path.Absolute()) - if err != nil { - Config.SysLog.Error("", "failed to open %s: %s\n", responder.Request.Path.Absolute(), err.Error()) - return &GophorError{ FileOpenErr, err } - } - - /* Read files in directory */ - files, err := fd.Readdir(-1) - if err != nil { - Config.SysLog.Error("", "failed to enumerate dir %s: %s\n", responder.Request.Path.Absolute(), err.Error()) - return &GophorError{ DirListErr, err } - } - - /* Sort the files by name */ - sort.Sort(byName(files)) - - /* Create directory content slice, ready */ - dirContents := make([]byte, 0) - - /* Walk through files :D */ - var reqPath *RequestPath - for _, file := range files { - reqPath = NewRequestPath(responder.Request.Path.RootDir(), responder.Request.Path.JoinRel(file.Name())) - - /* If hidden file, or restricted file, continue! */ - if isHiddenFile(hidden, reqPath.Relative()) /*|| isRestrictedFile(reqPath.Relative())*/ { - continue - } - - /* Handle file, directory or ignore others */ - switch { - case file.Mode() & os.ModeDir != 0: - /* Directory -- create directory listing */ - dirContents = append(dirContents, buildLine(TypeDirectory, file.Name(), reqPath.Selector(), responder.Host.Name(), responder.Host.Port())...) - - case file.Mode() & os.ModeType == 0: - /* Regular file -- find item type and creating listing */ - itemPath := reqPath.Selector() - itemType := getItemType(itemPath) - dirContents = append(dirContents, buildLine(itemType, file.Name(), reqPath.Selector(), responder.Host.Name(), responder.Host.Port())...) - - default: - /* Ignore */ - } - } - - /* Finally write dirContents and return result */ - return responder.WriteData(dirContents) -} - -/* Helper function to simple checking in map */ -func isHiddenFile(hiddenMap map[string]bool, fileName string) bool { - _, ok := hiddenMap[fileName] - return ok -} - -/* Took a leaf out of go-gopher's book here. */ -type byName []os.FileInfo -func (s byName) Len() int { return len(s) } -func (s byName) Less(i, j int) bool { return s[i].Name() < s[j].Name() } -func (s byName) Swap(i, j int) { s[i], s[j] = s[j], s[i] } diff --git a/fixedmap.go b/fixedmap.go deleted file mode 100644 index 7823a9c..0000000 --- a/fixedmap.go +++ /dev/null @@ -1,78 +0,0 @@ -package main - -import ( - "container/list" -) - -/* TODO: work on efficiency. use our own lower level data structure? */ - -/* FixedMap: - * A fixed size map that pushes the last - * used value from the stack if size limit - * is reached. - */ -type FixedMap struct { - Map map[string]*MapElement - List *list.List - Size int -} - -/* MapElement: - * Simple structure to wrap pointer to list - * element and stored map value together. - */ -type MapElement struct { - Element *list.Element - Value *File -} - -func NewFixedMap(size int) *FixedMap { - return &FixedMap{ - make(map[string]*MapElement), - list.New(), - size, - } -} - -/* Get file in map for key, or nil */ -func (fm *FixedMap) Get(key string) *File { - elem, ok := fm.Map[key] - if ok { - /* And that's an LRU implementation folks! */ - fm.List.MoveToFront(elem.Element) - return elem.Value - } else { - return nil - } -} - -/* Put file in map as key, pushing out last file if size limit reached */ -func (fm *FixedMap) Put(key string, value *File) { - element := fm.List.PushFront(key) - fm.Map[key] = &MapElement{ element, value } - - if fm.List.Len() > fm.Size { - /* We're at capacity! SIR! */ - element = fm.List.Back() - - /* We don't check here as we know this is ALWAYS a string */ - key, _ := element.Value.(string) - - /* Finally delete the map entry and list element! */ - delete(fm.Map, key) - fm.List.Remove(element) - } -} - -/* Try delete element, else do nothing */ -func (fm *FixedMap) Remove(key string) { - elem, ok := fm.Map[key] - if !ok { - /* We don't have this key, return */ - return - } - - /* Remove the selected element */ - delete(fm.Map, key) - fm.List.Remove(elem.Element) -} diff --git a/format.go b/format.go deleted file mode 100644 index 277882f..0000000 --- a/format.go +++ /dev/null @@ -1,50 +0,0 @@ -package main - -import ( - "strings" -) - -/* Formats an info-text footer from string. Add last line as we use the footer to contain last line (regardless if empty) */ -func formatGophermapFooter(text string, useSeparator bool) []byte { - ret := make([]byte, 0) - if text != "" { - ret = append(ret, buildInfoLine("")...) - if useSeparator { - ret = append(ret, buildInfoLine(buildLineSeparator(Config.PageWidth))...) - } - for _, line := range strings.Split(text, "\n") { - ret = append(ret, buildInfoLine(line)...) - } - } - return append(ret, []byte(LastLine)...) -} - -/* Replace standard replacement strings */ -func replaceStrings(str string, connHost *ConnHost) []byte { - /* We only replace the actual host and port values */ - split := strings.Split(str, Tab) - if len(split) < 4 { - return []byte(str) - } - - split[2] = strings.Replace(split[2], ReplaceStrHostname, connHost.Name(), -1) - split[3] = strings.Replace(split[3], ReplaceStrPort, connHost.Port(), -1) - - /* Return slice */ - b := make([]byte, 0) - - /* Recombine the slices and add the removed tabs */ - splitLen := len(split) - for i := 0; i < splitLen-1; i += 1 { - split[i] += Tab - b = append(b, []byte(split[i])...) - } - b = append(b, []byte(split[splitLen-1])...) - - return b -} - -/* Replace new-line characters */ -func replaceNewLines(str string) string { - return strings.Replace(str, "\n", "", -1) -} diff --git a/gopher.go b/gopher.go deleted file mode 100644 index dcc55be..0000000 --- a/gopher.go +++ /dev/null @@ -1,237 +0,0 @@ -package main - -import ( - "strings" -) - -type GopherUrl struct { - Path string - Parameters string -} - -const ( - /* Just naming some constants */ - DOSLineEnd = "\r\n" - UnixLineEnd = "\n" - End = "." - Tab = "\t" - LastLine = End+DOSLineEnd - - /* Gopher line formatting */ - MaxUserNameLen = 70 /* RFC 1436 standard, though we use user-supplied page-width */ - MaxSelectorLen = 255 /* RFC 1436 standard */ - SelectorErrorStr = "/max_selector_length_reached" - GophermapRenderErrorStr = "" - GophermapReadErrorStr = "Error reading subgophermap: " - GophermapExecErrorStr = "Error executing gophermap: " - - /* Default null values */ - NullSelector = "-" - NullHost = "null.host" - NullPort = "0" - - /* Replacement strings */ - ReplaceStrHostname = "$hostname" - ReplaceStrPort = "$port" -) - -/* - * Item type characters: - * Collected from RFC 1436 standard, Wikipedia, Go-gopher project - * and Gophernicus project. Those with ALL-CAPS descriptions in - * [square brackets] defined and used by Gophernicus, a popular - * Gopher server. - */ -type ItemType byte -const ( - /* RFC 1436 Standard */ - TypeFile = ItemType('0') /* Regular file (text) */ - TypeDirectory = ItemType('1') /* Directory (menu) */ - TypeDatabase = ItemType('2') /* CCSO flat db; other db */ - TypeError = ItemType('3') /* Error message */ - TypeMacBinHex = ItemType('4') /* Macintosh BinHex file */ - TypeBinArchive = ItemType('5') /* Binary archive (zip, rar, 7zip, tar, gzip, etc), CLIENT MUST READ UNTIL TCP CLOSE */ - TypeUUEncoded = ItemType('6') /* UUEncoded archive */ - TypeSearch = ItemType('7') /* Query search engine or CGI script */ - TypeTelnet = ItemType('8') /* Telnet to: VT100 series server */ - TypeBin = ItemType('9') /* Binary file (see also, 5), CLIENT MUST READ UNTIL TCP CLOSE */ - TypeTn3270 = ItemType('T') /* Telnet to: tn3270 series server */ - TypeGif = ItemType('g') /* GIF format image file (just use I) */ - TypeImage = ItemType('I') /* Any format image file */ - TypeRedundant = ItemType('+') /* Redundant (indicates mirror of previous item) */ - - /* GopherII Standard */ - TypeCalendar = ItemType('c') /* Calendar file */ - TypeDoc = ItemType('d') /* Word-processing document; PDF document */ - TypeHtml = ItemType('h') /* HTML document */ - TypeInfo = ItemType('i') /* Informational text (not selectable) */ - TypeMarkup = ItemType('p') /* Page layout or markup document (plain text w/ ASCII tags) */ - TypeMail = ItemType('M') /* Email repository (MBOX) */ - TypeAudio = ItemType('s') /* Audio recordings */ - TypeXml = ItemType('x') /* eXtensible Markup Language document */ - TypeVideo = ItemType(';') /* Video files */ - - /* Commonly Used */ - TypeTitle = ItemType('!') /* [SERVER ONLY] Menu title (set title ONCE per gophermap) */ - TypeComment = ItemType('#') /* [SERVER ONLY] Comment, rest of line is ignored */ - TypeHiddenFile = ItemType('-') /* [SERVER ONLY] Hide file/directory from directory listing */ - TypeEnd = ItemType('.') /* [SERVER ONLY] Last line -- stop processing gophermap default */ - TypeSubGophermap = ItemType('=') /* [SERVER ONLY] Include subgophermap / regular file here. */ - TypeEndBeginList = ItemType('*') /* [SERVER ONLY] Last line + directory listing -- stop processing gophermap and end on directory listing */ - - /* Default type */ - TypeDefault = TypeBin - - /* Gophor specific types */ - TypeInfoNotStated = ItemType('I') /* [INTERNAL USE] */ - TypeUnknown = ItemType('?') /* [INTERNAL USE] */ -) - -var FileExtMap = map[string]ItemType{ - ".out": TypeBin, - ".a": TypeBin, - ".o": TypeBin, - ".ko": TypeBin, /* ... Though tbh, kernel extensions?!!! */ - ".msi": TypeBin, - ".exe": TypeBin, - - ".gophermap": TypeDirectory, - - ".lz": TypeBinArchive, - ".gz": TypeBinArchive, - ".bz2": TypeBinArchive, - ".7z": TypeBinArchive, - ".zip": TypeBinArchive, - - ".gitignore": TypeFile, - ".txt": TypeFile, - ".json": TypeFile, - ".yaml": TypeFile, - ".ocaml": TypeFile, - ".s": TypeFile, - ".c": TypeFile, - ".py": TypeFile, - ".h": TypeFile, - ".go": TypeFile, - ".fs": TypeFile, - ".odin": TypeFile, - ".nanorc": TypeFile, - ".bashrc": TypeFile, - ".mkshrc": TypeFile, - ".vimrc": TypeFile, - ".vim": TypeFile, - ".viminfo": TypeFile, - ".sh": TypeFile, - ".conf": TypeFile, - ".xinitrc": TypeFile, - ".jstarrc": TypeFile, - ".joerc": TypeFile, - ".jpicorc": TypeFile, - ".profile": TypeFile, - ".bash_profile": TypeFile, - ".bash_logout": TypeFile, - ".log": TypeFile, - ".ovpn": TypeFile, - - ".md": TypeMarkup, - - ".xml": TypeXml, - - ".doc": TypeDoc, - ".docx": TypeDoc, - ".pdf": TypeDoc, - - ".jpg": TypeImage, - ".jpeg": TypeImage, - ".png": TypeImage, - ".gif": TypeImage, - - ".html": TypeHtml, - ".htm": TypeHtml, - - ".ogg": TypeAudio, - ".mp3": TypeAudio, - ".wav": TypeAudio, - ".mod": TypeAudio, - ".it": TypeAudio, - ".xm": TypeAudio, - ".mid": TypeAudio, - ".vgm": TypeAudio, - ".opus": TypeAudio, - ".m4a": TypeAudio, - ".aac": TypeAudio, - - ".mp4": TypeVideo, - ".mkv": TypeVideo, - ".webm": TypeVideo, -} - -/* Build error line */ -func buildErrorLine(selector string) []byte { - ret := string(TypeError) - ret += selector + DOSLineEnd - ret += LastLine - return []byte(ret) -} - -/* Build gopher compliant line with supplied information */ -func buildLine(t ItemType, name, selector, host string, port string) []byte { - ret := string(t) - - /* Add name, truncate name if too long */ - if len(name) > Config.PageWidth { - ret += name[:Config.PageWidth-5]+"..."+Tab - } else { - ret += name+Tab - } - - /* Add selector. If too long use err, skip if empty */ - selectorLen := len(selector) - if selectorLen > MaxSelectorLen { - ret += SelectorErrorStr+Tab - } else if selectorLen > 0 { - ret += selector+Tab - } - - /* Add host + port */ - ret += host+Tab+port+DOSLineEnd - - return []byte(ret) -} - -/* Build gopher compliant info line */ -func buildInfoLine(content string) []byte { - return buildLine(TypeInfo, content, NullSelector, NullHost, NullPort) -} - -/* Get item type for named file on disk */ -func getItemType(name string) ItemType { - /* Split, name MUST be lower */ - split := strings.Split(strings.ToLower(name), ".") - - /* First we look at how many '.' in name string */ - splitLen := len(split) - switch splitLen { - case 0: - /* Always return TypeDefault. We can never tell */ - return TypeDefault - - default: - /* Get index of str after last ".", look in FileExtMap */ - fileType, ok := FileExtMap["."+split[splitLen-1]] - if ok { - return fileType - } else { - return TypeDefault - } - } -} - -/* Build a line separator of supplied width */ -func buildLineSeparator(count int) string { - ret := "" - for i := 0; i < count; i += 1 { - ret += "_" - } - return ret -} diff --git a/gopher/error.go b/gopher/error.go new file mode 100644 index 0000000..e7299f8 --- /dev/null +++ b/gopher/error.go @@ -0,0 +1,92 @@ +package gopher + +import "gophor/core" + +// Gopher specific error codes +const ( + InvalidGophermapErr core.ErrorCode = 1 + SubgophermapIsDirErr core.ErrorCode = 2 + SubgophermapSizeErr core.ErrorCode = 3 +) + +// generateErrorMessage returns a message for any gopher specific error codes +func generateErrorMessage(code core.ErrorCode) string { + switch code { + case InvalidGophermapErr: + return invalidGophermapErrStr + case SubgophermapIsDirErr: + return subgophermapIsDirErrStr + case SubgophermapSizeErr: + return subgophermapSizeErrStr + default: + return unknownErrStr + } +} + +// generateErrorResponse takes an error code and generates an error response byte slice +func generateErrorResponse(code core.ErrorCode) ([]byte, bool) { + switch code { + case core.ConnWriteErr: + return nil, false // no point responding if we couldn't write + case core.ConnReadErr: + return buildErrorLine(errorResponse503), true + case core.ConnCloseErr: + return nil, false // no point responding if we couldn't close + case core.ListenerResolveErr: + return nil, false // not user facing + case core.ListenerBeginErr: + return nil, false // not user facing + case core.ListenerAcceptErr: + return nil, false // not user facing + case core.InvalidIPErr: + return nil, false // not user facing + case core.InvalidPortErr: + return nil, false // not user facing + case core.FileOpenErr: + return buildErrorLine(errorResponse404), true + case core.FileStatErr: + return buildErrorLine(errorResponse500), true + case core.FileReadErr: + return buildErrorLine(errorResponse500), true + case core.FileTypeErr: + return buildErrorLine(errorResponse404), true + case core.DirectoryReadErr: + return buildErrorLine(errorResponse500), true + case core.RestrictedPathErr: + return buildErrorLine(errorResponse403), true + case core.InvalidRequestErr: + return buildErrorLine(errorResponse400), true + case core.CGIStartErr: + return buildErrorLine(errorResponse500), true + case core.CGIExitCodeErr: + return buildErrorLine(errorResponse500), true + case core.CGIStatus400Err: + return buildErrorLine(errorResponse400), true + case core.CGIStatus401Err: + return buildErrorLine(errorResponse401), true + case core.CGIStatus403Err: + return buildErrorLine(errorResponse403), true + case core.CGIStatus404Err: + return buildErrorLine(errorResponse404), true + case core.CGIStatus408Err: + return buildErrorLine(errorResponse408), true + case core.CGIStatus410Err: + return buildErrorLine(errorResponse410), true + case core.CGIStatus500Err: + return buildErrorLine(errorResponse500), true + case core.CGIStatus501Err: + return buildErrorLine(errorResponse501), true + case core.CGIStatus503Err: + return buildErrorLine(errorResponse503), true + case core.CGIStatusUnknownErr: + return buildErrorLine(errorResponse500), true + case InvalidGophermapErr: + return buildErrorLine(errorResponse500), true + case SubgophermapIsDirErr: + return buildErrorLine(errorResponse500), true + case SubgophermapSizeErr: + return buildErrorLine(errorResponse500), true + default: + return nil, false + } +} diff --git a/gopher/filecontents.go b/gopher/filecontents.go new file mode 100644 index 0000000..f890b01 --- /dev/null +++ b/gopher/filecontents.go @@ -0,0 +1,36 @@ +package gopher + +import ( + "gophor/core" + "os" +) + +// gophermapContents is an implementation of core.FileContents that holds individually renderable sections of a gophermap +type gophermapContents struct { + sections []gophermapSection +} + +// WriteToClient renders each cached section of the gophermap, and writes them to the client +func (gc *gophermapContents) WriteToClient(client *core.Client, path *core.Path) core.Error { + for _, section := range gc.sections { + err := section.RenderAndWrite(client) + if err != nil { + return err + } + } + + // Finally, write the footer (including last-line) + return client.Conn().WriteBytes(footer) +} + +// Load takes an open FD and loads the gophermap contents into memory as different renderable sections +func (gc *gophermapContents) Load(fd *os.File, path *core.Path) core.Error { + var err core.Error + gc.sections, err = readGophermap(fd, path) + return err +} + +// Clear empties currently cached GophermapContents memory +func (gc *gophermapContents) Clear() { + gc.sections = nil +} diff --git a/gopher/format.go b/gopher/format.go new file mode 100644 index 0000000..35fbee6 --- /dev/null +++ b/gopher/format.go @@ -0,0 +1,96 @@ +package gopher + +import ( + "gophor/core" + "os" + "strings" +) + +// Gophermap line formatting constants +const ( + maxSelectorLen = 255 + nullHost = "null.host" + nullPort = "0" + errorSelector = "/error_selector_length" +) + +var ( + // pageWidth is the maximum set page width of a gophermap document to render to + pageWidth int + + // footer holds the formatted footer text (if supplied), and gophermap last-line + footer []byte +) + +// formatName formats a gopher line name string +func formatName(name string) string { + if len(name) > pageWidth { + return name[:pageWidth-4] + "...\t" + } + return name + "\t" +} + +// formatSelector formats a gopher line selector string +func formatSelector(selector string) string { + if len(selector) > maxSelectorLen { + return errorSelector + "\t" + } + return selector + "\t" +} + +// formatHostPort formats a gopher line host + port +func formatHostPort(host, port string) string { + return host + "\t" + port +} + +// buildLine builds a gopher line string +func buildLine(t ItemType, name, selector, host, port string) []byte { + return []byte(string(t) + formatName(name) + formatSelector(selector) + formatHostPort(host, port) + "\r\n") +} + +// buildInfoLine builds a gopher info line string +func buildInfoLine(line string) []byte { + return []byte(string(typeInfo) + formatName(line) + formatHostPort(nullHost, nullPort) + "\r\n") +} + +// buildErrorLine builds a gopher error line string +func buildErrorLine(selector string) []byte { + return []byte(string(typeError) + selector + "\r\n" + ".\r\n") +} + +// appendFileListing formats and appends a new file entry as part of a directory listing +func appendFileListing(b []byte, file os.FileInfo, p *core.Path) []byte { + switch { + case file.Mode()&os.ModeDir != 0: + return append(b, buildLine(typeDirectory, file.Name(), p.Selector(), core.Hostname, core.FwdPort)...) + case file.Mode()&os.ModeType == 0: + t := getItemType(p.Relative()) + return append(b, buildLine(t, file.Name(), p.Selector(), core.Hostname, core.FwdPort)...) + default: + return b + } +} + +// buildFooter formats a raw gopher footer ready to attach to end of gophermaps (including DOS line-end) +func buildFooter(raw string) []byte { + ret := make([]byte, 0) + + if raw != "" { + ret = append(ret, buildInfoLine(footerLineSeparator())...) + + for _, line := range strings.Split(raw, "\n") { + ret = append(ret, buildInfoLine(line)...) + } + } + + return append(ret, []byte(".\r\n")...) +} + +// footerLineSeparator is an internal function that generates a footer line separator string +func footerLineSeparator() string { + ret := "" + for i := 0; i < pageWidth; i++ { + ret += "_" + } + return ret +} diff --git a/gopher/gophermap.go b/gopher/gophermap.go new file mode 100644 index 0000000..65be560 --- /dev/null +++ b/gopher/gophermap.go @@ -0,0 +1,240 @@ +package gopher + +import ( + "gophor/core" + "os" +) + +var ( + // subgophermapSizeMax specifies the maximum size of an included subgophermap + subgophermapSizeMax int64 +) + +// GophermapSection is an interface that specifies individually renderable (and writeable) sections of a gophermap +type gophermapSection interface { + RenderAndWrite(*core.Client) core.Error +} + +// readGophermap reads a FD and Path as gophermap sections +func readGophermap(fd *os.File, p *core.Path) ([]gophermapSection, core.Error) { + // Create return slice + sections := make([]gophermapSection, 0) + + // Create hidden files map now in case later requested + hidden := map[string]bool{ + p.Relative(): true, + } + + // Error setting within nested function below + var returnErr core.Error + + // Perform scan of gophermap FD + titleAlready := false + scanErr := core.FileSystem.ScanFile( + fd, + func(line string) bool { + // Parse the line item type and handle + lineType := parseLineType(line) + switch lineType { + case typeInfoNotStated: + // Append TypeInfo to beginning of line + sections = append(sections, &TextSection{buildInfoLine(line)}) + return true + + case typeTitle: + // Reformat title line to send as info line with appropriate selector + if !titleAlready { + sections = append(sections, &TextSection{buildLine(typeInfo, line[1:], "TITLE", nullHost, nullPort)}) + titleAlready = true + return true + } + returnErr = core.NewError(InvalidGophermapErr) + return false + + case typeComment: + // ignore this line + return true + + case typeHiddenFile: + // Add to hidden files map + hidden[p.JoinRelative(line[1:])] = true + return true + + case typeSubGophermap: + // Parse new Path and parameters + request := core.ParseInternalRequest(p, line[1:]) + if returnErr != nil { + return false + } else if request.Path().Relative() == "" || request.Path().Relative() == p.Relative() { + returnErr = core.NewError(InvalidGophermapErr) + return false + } + + // Open FD + var subFD *os.File + subFD, returnErr = core.FileSystem.OpenFile(request.Path()) + if returnErr != nil { + return false + } + + // Get stat + stat, err := subFD.Stat() + if err != nil { + returnErr = core.WrapError(core.FileStatErr, err) + return false + } else if stat.IsDir() { + returnErr = core.NewError(SubgophermapIsDirErr) + return false + } + + // Handle CGI script + if core.WithinCGIDir(request.Path()) { + sections = append(sections, &CGISection{request}) + return true + } + + // Error out if file too big + if stat.Size() > subgophermapSizeMax { + returnErr = core.NewError(SubgophermapSizeErr) + return false + } + + // Handle regular file + if !isGophermap(request.Path()) { + sections = append(sections, &FileSection{}) + return true + } + + // Handle gophermap + sections = append(sections, &SubgophermapSection{}) + return true + + case typeEnd: + // Last line, break-out! + return false + + case typeEndBeginList: + // Append DirectorySection object then break, as-with typeEnd + dirPath := p.Dir() + sections = append(sections, &DirectorySection{hidden, dirPath}) + return false + + default: + // Default is appending to sections slice as TextSection + sections = append(sections, &TextSection{[]byte(line + "\r\n")}) + return true + } + }, + ) + + // Check the scan didn't exit with error + if returnErr != nil { + return nil, returnErr + } else if scanErr != nil { + return nil, scanErr + } + + return sections, nil +} + +// TextSection is a simple implementation that holds line's byte contents as-is +type TextSection struct { + contents []byte +} + +// RenderAndWrite simply writes the byte slice to the client +func (s *TextSection) RenderAndWrite(client *core.Client) core.Error { + return client.Conn().WriteBytes(s.contents) +} + +// DirectorySection is an implementation that holds a dir path, and map of hidden files, to later list a dir contents +type DirectorySection struct { + hidden map[string]bool + path *core.Path +} + +// RenderAndWrite scans and renders a list of the contents of a directory (skipping hidden or restricted files) +func (s *DirectorySection) RenderAndWrite(client *core.Client) core.Error { + fd, err := core.FileSystem.OpenFile(s.path) + if err != nil { + return err + } + + // Slice to write + dirContents := make([]byte, 0) + + // Scan directory and build lines + err = core.FileSystem.ScanDirectory(fd, s.path, func(file os.FileInfo, p *core.Path) { + // Append new formatted file listing (if correct type) + dirContents = appendFileListing(dirContents, file, p) + }) + if err != nil { + return err + } + + // Write dirContents to client + return client.Conn().WriteBytes(dirContents) +} + +// FileSection is an implementation that holds a file path, and writes the file contents to client +type FileSection struct { + path *core.Path +} + +// RenderAndWrite simply opens, reads and writes the file contents to the client +func (s *FileSection) RenderAndWrite(client *core.Client) core.Error { + // Open FD for the file + fd, err := core.FileSystem.OpenFile(s.path) + if err != nil { + return err + } + + // Read the file contents into memory + b, err := core.FileSystem.ReadFile(fd) + if err != nil { + return err + } + + // Write the file contents to the client + return client.Conn().WriteBytes(b) +} + +// SubgophermapSection is an implementation to hold onto a gophermap path, then read, render and write contents to a client +type SubgophermapSection struct { + path *core.Path +} + +// RenderAndWrite reads, renders and writes the contents of the gophermap to the client +func (s *SubgophermapSection) RenderAndWrite(client *core.Client) core.Error { + // Get FD for gophermap + fd, err := core.FileSystem.OpenFile(s.path) + if err != nil { + return err + } + + // Read gophermap into sections + sections, err := readGophermap(fd, s.path) + if err != nil { + return err + } + + // Write each of the sections (AAAA COULD BE RECURSIONNNNN) + for _, section := range sections { + err := section.RenderAndWrite(client) + if err != nil { + return err + } + } + + return nil +} + +// CGISection is an implementation that holds onto a built request, then processing as a CGI request on request +type CGISection struct { + request *core.Request +} + +// RenderAndWrite takes the request, and executes the associated CGI script with parameters +func (s *CGISection) RenderAndWrite(client *core.Client) core.Error { + return core.ExecuteCGIScript(client, s.request) +} diff --git a/gopher/html.go b/gopher/html.go new file mode 100644 index 0000000..133cdf1 --- /dev/null +++ b/gopher/html.go @@ -0,0 +1,22 @@ +package gopher + +// generateHTMLRedirect takes a URL string and generates an HTML redirect page bytes +func generateHTMLRedirect(url string) []byte { + content := + "\n" + + "\n" + + "" + + "\n" + + "\n" + + "You are following an external link to a web site.\n" + + "You will be automatically taken to the site shortly.\n" + + "If you do not get sent there, please click here to go to the web site.\n" + + "

\n" + + "The URL linked is " + url + "\n" + + "

\n" + + "Thanks for using Gophor!\n" + + "\n" + + "\n" + + return []byte(content) +} diff --git a/gopher/itemtype.go b/gopher/itemtype.go new file mode 100644 index 0000000..07fd169 --- /dev/null +++ b/gopher/itemtype.go @@ -0,0 +1,192 @@ +package gopher + +import "strings" + +// ItemType specifies a gopher item type char +type ItemType byte + +// RFC 1436 Standard item types +const ( + typeFile = ItemType('0') /* Regular file (text) */ + typeDirectory = ItemType('1') /* Directory (menu) */ + typeDatabase = ItemType('2') /* CCSO flat db; other db */ + typeError = ItemType('3') /* Error message */ + typeMacBinHex = ItemType('4') /* Macintosh BinHex file */ + typeBinArchive = ItemType('5') /* Binary archive (zip, rar, 7zip, tar, gzip, etc), CLIENT MUST READ UNTIL TCP CLOSE */ + typeUUEncoded = ItemType('6') /* UUEncoded archive */ + typeSearch = ItemType('7') /* Query search engine or CGI script */ + typeTelnet = ItemType('8') /* Telnet to: VT100 series server */ + typeBin = ItemType('9') /* Binary file (see also, 5), CLIENT MUST READ UNTIL TCP CLOSE */ + typeTn3270 = ItemType('T') /* Telnet to: tn3270 series server */ + typeGif = ItemType('g') /* GIF format image file (just use I) */ + typeImage = ItemType('I') /* Any format image file */ + typeRedundant = ItemType('+') /* Redundant (indicates mirror of previous item) */ +) + +// GopherII Standard item types +const ( + typeCalendar = ItemType('c') /* Calendar file */ + typeDoc = ItemType('d') /* Word-processing document; PDF document */ + typeHTML = ItemType('h') /* HTML document */ + typeInfo = ItemType('i') /* Informational text (not selectable) */ + typeMarkup = ItemType('p') /* Page layout or markup document (plain text w/ ASCII tags) */ + typeMail = ItemType('M') /* Email repository (MBOX) */ + typeAudio = ItemType('s') /* Audio recordings */ + typeXML = ItemType('x') /* eXtensible Markup Language document */ + typeVideo = ItemType(';') /* Video files */ +) + +// Commonly Used item types +const ( + typeTitle = ItemType('!') /* [SERVER ONLY] Menu title (set title ONCE per gophermap) */ + typeComment = ItemType('#') /* [SERVER ONLY] Comment, rest of line is ignored */ + typeHiddenFile = ItemType('-') /* [SERVER ONLY] Hide file/directory from directory listing */ + typeEnd = ItemType('.') /* [SERVER ONLY] Last line -- stop processing gophermap default */ + typeSubGophermap = ItemType('=') /* [SERVER ONLY] Include subgophermap / regular file here. */ + typeEndBeginList = ItemType('*') /* [SERVER ONLY] Last line + directory listing -- stop processing gophermap and end on directory listing */ +) + +// Internal item types +const ( + typeDefault = typeBin + typeInfoNotStated = ItemType('I') + typeUnknown = ItemType('?') +) + +// fileExtMap specifies mapping of file extensions to gopher item types +var fileExtMap = map[string]ItemType{ + ".out": typeBin, + ".a": typeBin, + ".o": typeBin, + ".ko": typeBin, /* Kernel extensions... WHY ARE YOU GIVING ACCESS TO DIRECTORIES WITH THIS */ + + ".gophermap": typeDirectory, + + ".lz": typeBinArchive, + ".gz": typeBinArchive, + ".bz2": typeBinArchive, + ".7z": typeBinArchive, + ".zip": typeBinArchive, + + ".gitignore": typeFile, + ".txt": typeFile, + ".json": typeFile, + ".yaml": typeFile, + ".ocaml": typeFile, + ".s": typeFile, + ".c": typeFile, + ".py": typeFile, + ".h": typeFile, + ".go": typeFile, + ".fs": typeFile, + ".odin": typeFile, + ".nanorc": typeFile, + ".bashrc": typeFile, + ".mkshrc": typeFile, + ".vimrc": typeFile, + ".vim": typeFile, + ".viminfo": typeFile, + ".sh": typeFile, + ".conf": typeFile, + ".xinitrc": typeFile, + ".jstarrc": typeFile, + ".joerc": typeFile, + ".jpicorc": typeFile, + ".profile": typeFile, + ".bash_profile": typeFile, + ".bash_logout": typeFile, + ".log": typeFile, + ".ovpn": typeFile, + + ".md": typeMarkup, + + ".xml": typeXML, + + ".doc": typeDoc, + ".docx": typeDoc, + ".pdf": typeDoc, + + ".jpg": typeImage, + ".jpeg": typeImage, + ".png": typeImage, + ".gif": typeImage, + + ".html": typeHTML, + ".htm": typeHTML, + + ".ogg": typeAudio, + ".mp3": typeAudio, + ".wav": typeAudio, + ".mod": typeAudio, + ".it": typeAudio, + ".xm": typeAudio, + ".mid": typeAudio, + ".vgm": typeAudio, + ".opus": typeAudio, + ".m4a": typeAudio, + ".aac": typeAudio, + + ".mp4": typeVideo, + ".mkv": typeVideo, + ".webm": typeVideo, + ".avi": typeVideo, +} + +// getItemType is an internal function to get an ItemType for a file name string +func getItemType(name string) ItemType { + // Split, name MUST be lower + split := strings.Split(strings.ToLower(name), ".") + + // First we look at how many '.' in name string + splitLen := len(split) + switch splitLen { + case 0: + // Always return typeDefault, we can't tell + return typeDefault + + default: + // get index of str after last '.', look up in fileExtMap + fileType, ok := fileExtMap["."+split[splitLen-1]] + if ok { + return fileType + } + return typeDefault + } +} + +// parseLineType parses a gophermap's line type based on first char and contents +func parseLineType(line string) ItemType { + lineLen := len(line) + + if lineLen == 0 { + return typeInfoNotStated + } + + // Get ItemType for first char + t := ItemType(line[0]) + + if lineLen == 1 { + // The only accepted types for length 1 line below: + t := ItemType(line[0]) + if t == typeEnd || + t == typeEndBeginList || + t == typeComment || + t == typeInfo || + t == typeTitle { + return t + } + return typeUnknown + } else if !strings.Contains(line, "\t") { + // The only accepted types for length >= 1 and with a tab + if t == typeComment || + t == typeTitle || + t == typeInfo || + t == typeHiddenFile || + t == typeSubGophermap { + return t + } + return typeInfoNotStated + } + + return t +} diff --git a/gopher/main.go b/gopher/main.go new file mode 100644 index 0000000..134a436 --- /dev/null +++ b/gopher/main.go @@ -0,0 +1,37 @@ +package gopher + +import ( + "flag" + "gophor/core" +) + +// setup parses gopher specific flags, and all core flags, preparing server for .Run() +func setup() { + pWidth := flag.Uint(pageWidthFlagStr, 80, pageWidthDescStr) + footerText := flag.String(footerTextFlagStr, "Gophor, a gopher server in Go!", footerTextDescStr) + subgopherSizeMax := flag.Float64(subgopherSizeMaxFlagStr, 1.0, subgopherSizeMaxDescStr) + admin := flag.String(adminFlagStr, "", adminDescStr) + desc := flag.String(descFlagStr, "", descDescStr) + geo := flag.String(geoFlagStr, "", geoDescStr) + core.ParseFlagsAndSetup(generateErrorMessage) + + // Setup gopher specific global variables + subgophermapSizeMax = int64(1048576.0 * *subgopherSizeMax) // convert float to megabytes + pageWidth = int(*pWidth) + footer = buildFooter(*footerText) + gophermapRegex = compileGophermapRegex() + + // Generate capability files + capsTxt := generateCapsTxt(*desc, *admin, *geo) + robotsTxt := generateRobotsTxt() + + // Add generated files to cache + core.FileSystem.AddGeneratedFile(core.NewPath(core.Root, "caps.txt"), capsTxt) + core.FileSystem.AddGeneratedFile(core.NewPath(core.Root, "robots.txt"), robotsTxt) +} + +// Run does as says :) +func Run() { + setup() + core.Start(serve) +} diff --git a/gopher/policy.go b/gopher/policy.go new file mode 100644 index 0000000..6fde523 --- /dev/null +++ b/gopher/policy.go @@ -0,0 +1,48 @@ +package gopher + +import "gophor/core" + +func generatePolicyHeader(name string) string { + text := "# This is an automatically generated" + "\r\n" + text += "# server policy file: " + name + "\r\n" + text += "#" + "\r\n" + text += "# BlackLivesMatter" + "\r\n" + return text +} + +func generateCapsTxt(desc, admin, geo string) []byte { + text := "CAPS" + "\r\n" + text += "\r\n" + text += generatePolicyHeader("caps.txt") + text += "\r\n" + text += "CapsVersion=1" + "\r\n" + text += "ExpireCapsAfter=1800" + "\r\n" + text += "\r\n" + text += "PathDelimeter=/" + "\r\n" + text += "PathIdentity=." + "\r\n" + text += "PathParent=.." + "\r\n" + text += "PathParentDouble=FALSE" + "\r\n" + text += "PathEscapeCharacter=\\" + "\r\n" + text += "PathKeepPreDelimeter=FALSE" + "\r\n" + text += "\r\n" + text += "ServerSoftware=Gophor" + "\r\n" + text += "ServerSoftwareVersion=" + core.Version + "\r\n" + text += "ServerDescription=" + desc + "\r\n" + text += "ServerGeolocationString=" + geo + "\r\n" + text += "ServerDefaultEncoding=utf-8" + "\r\n" + text += "\r\n" + text += "ServerAdmin=" + admin + "\r\n" + return []byte(text) +} + +func generateRobotsTxt() []byte { + text := generatePolicyHeader("robots.txt") + text += "\r\n" + text += "Usage-agent: *" + "\r\n" + text += "Disallow: *" + "\r\n" + text += "\r\n" + text += "Crawl-delay: 99999" + "\r\n" + text += "\r\n" + text += "# This server does not support scraping" + "\r\n" + return []byte(text) +} diff --git a/gopher/regex.go b/gopher/regex.go new file mode 100644 index 0000000..2aa49fd --- /dev/null +++ b/gopher/regex.go @@ -0,0 +1,21 @@ +package gopher + +import ( + "gophor/core" + "regexp" +) + +var ( + // gophermapRegex is the precompiled gophermap file name regex check + gophermapRegex *regexp.Regexp +) + +// compileGophermapRegex compiles the gophermap file name check regex +func compileGophermapRegex() *regexp.Regexp { + return regexp.MustCompile(`^(|.+/|.+\.)gophermap$`) +} + +// isGophermap checks against gophermap regex as to whether a file path is a gophermap +func isGophermap(path *core.Path) bool { + return gophermapRegex.MatchString(path.Relative()) +} diff --git a/gopher/server.go b/gopher/server.go new file mode 100644 index 0000000..7b31676 --- /dev/null +++ b/gopher/server.go @@ -0,0 +1,110 @@ +package gopher + +import ( + "gophor/core" + "os" + "strings" +) + +// serve is the global gopher server's serve function +func serve(client *core.Client) { + // Receive line from client + received, err := client.Conn().ReadLine() + if err != nil { + client.LogError(clientReadFailStr) + handleError(client, err) + return + } + + // Convert to string + line := string(received) + + // If prefixed by 'URL:' send a redirect + lenBefore := len(line) + line = strings.TrimPrefix(line, "URL:") + if len(line) < lenBefore { + client.Conn().WriteBytes(generateHTMLRedirect(line)) + client.LogInfo(clientRedirectFmtStr, line) + return + } + + // Parse new request + request, err := core.ParseURLEncodedRequest(line) + if err != nil { + client.LogError(clientRequestParseFailStr) + handleError(client, err) + return + } + + // Handle the request! + err = core.FileSystem.HandleClient( + client, + request, + newFileContents, + func(fs *core.FileSystemObject, client *core.Client, fd *os.File, p *core.Path) core.Error { + // First check for gophermap, create gophermap Path object + gophermap := p.JoinPath("gophermap") + + // If gophermap exists, we fetch this + fd2, err := fs.OpenFile(gophermap) + if err == nil { + stat, osErr := fd2.Stat() + if osErr == nil { + return fs.FetchFile(client, fd2, stat, gophermap, newFileContents) + } + + // Else, just close fd2 + fd2.Close() + } + + // Slice to write + dirContents := make([]byte, 0) + + // Add directory heading + empty line + dirContents = append(dirContents, buildLine(typeInfo, "[ "+core.Hostname+p.Selector()+" ]", "TITLE", nullHost, nullPort)...) + dirContents = append(dirContents, buildInfoLine("")...) + + // Scan directory and build lines + err = fs.ScanDirectory( + fd, + p, + func(file os.FileInfo, fp *core.Path) { + // Append new formatted file listing (if correct type) + dirContents = appendFileListing(dirContents, file, fp) + }, + ) + if err != nil { + return err + } + + // Add footer, write contents + dirContents = append(dirContents, footer...) + return client.Conn().WriteBytes(dirContents) + }, + ) + + // Final error handling + if err != nil { + handleError(client, err) + client.LogError(clientServeFailStr, request.Path().Absolute()) + } else { + client.LogInfo(clientServedStr, request.Path().Absolute()) + } +} + +// handleError determines whether to send an error response to the client, and logs to system +func handleError(client *core.Client, err core.Error) { + response, ok := generateErrorResponse(err.Code()) + if ok { + client.Conn().WriteBytes(response) + } + core.SystemLog.Error(err.Error()) +} + +// newFileContents returns a new FileContents object +func newFileContents(p *core.Path) core.FileContents { + if isGophermap(p) { + return &gophermapContents{} + } + return &core.RegularFileContents{} +} diff --git a/gopher/string_constants.go b/gopher/string_constants.go new file mode 100644 index 0000000..07863b0 --- /dev/null +++ b/gopher/string_constants.go @@ -0,0 +1,49 @@ +package gopher + +// Client error response strings +const ( + errorResponse400 = "400 Bad Request" + errorResponse401 = "401 Unauthorised" + errorResponse403 = "403 Forbidden" + errorResponse404 = "404 Not Found" + errorResponse408 = "408 Request Time-out" + errorResponse410 = "410 Gone" + errorResponse500 = "500 Internal Server Error" + errorResponse501 = "501 Not Implemented" + errorResponse503 = "503 Service Unavailable" +) + +// Gopher flag string constants +const ( + pageWidthFlagStr = "page-width" + pageWidthDescStr = "Gopher page width" + + footerTextFlagStr = "footer-text" + footerTextDescStr = "Footer text (empty to disable)" + + subgopherSizeMaxFlagStr = "subgopher-size-max" + subgopherSizeMaxDescStr = "Subgophermap size max (megabytes)" + + adminFlagStr = "admin" + adminDescStr = "Generated policy file admin email" + + descFlagStr = "description" + descDescStr = "Generated policy file server description" + + geoFlagStr = "geolocation" + geoDescStr = "Generated policy file server geolocation" +) + +// Log string constants +const ( + clientReadFailStr = "Failed to read" + clientRedirectFmtStr = "Redirecting to: %s" + clientRequestParseFailStr = "Failed to parse request" + clientServeFailStr = "Failed to serve: %s" + clientServedStr = "Served: %s" + + invalidGophermapErrStr = "Invalid gophermap" + subgophermapIsDirErrStr = "Subgophermap path is dir" + subgophermapSizeErrStr = "Subgophermap size too large" + unknownErrStr = "Unknown error code" +) diff --git a/gophor.go b/gophor.go deleted file mode 100644 index dc73a4f..0000000 --- a/gophor.go +++ /dev/null @@ -1,254 +0,0 @@ -package main - -import ( - "os" - "log" - "strconv" - "syscall" - "os/signal" - "flag" - "time" -) - -const ( - GophorVersion = "1.0-beta" -) - -var ( - Config *ServerConfig -) - -func main() { - /* Quickly setup global logger */ - setupGlobalLogger() - - /* Setup the entire server, getting slice of listeners in return */ - listeners := setupServer() - - /* Handle signals so we can _actually_ shutdowm */ - signals := make(chan os.Signal) - signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM, syscall.SIGKILL) - - /* Start accepting connections on any supplied listeners */ - for _, l := range listeners { - go func() { - Config.SysLog.Info("", "Listening on: gopher://%s:%s\n", l.Host.Name(), l.Host.RealPort()) - - for { - worker, err := l.Accept() - if err != nil { - Config.SysLog.Error("", "Error accepting connection: %s\n", err.Error()) - continue - } - go worker.Serve() - } - }() - } - - /* When OS signal received, we close-up */ - sig := <-signals - Config.SysLog.Info("", "Signal received: %v. Shutting down...\n", sig) - os.Exit(0) -} - -func setupServer() []*GophorListener { - /* First we setup all the flags and parse them... */ - - /* Base server settings */ - serverRoot := flag.String("root", "/var/gopher", "Change server root directory.") - serverBindAddr := flag.String("bind-addr", "", "Change server socket bind address") - serverPort := flag.Int("port", 70, "Change server bind port.") - - serverFwdPort := flag.Int("fwd-port", 0, "Change port used in '$port' replacement strings (useful if you're port forwarding).") - serverHostname := flag.String("hostname", "", "Change server hostname (FQDN).") - - /* Logging settings */ - systemLogPath := flag.String("system-log", "", "Change server system log file (blank outputs to stderr).") - accessLogPath := flag.String("access-log", "", "Change server access log file (blank outputs to stderr).") - logOutput := flag.String("log-output", "stderr", "Change server log file handling (disable|stderr|file)") - logOpts := flag.String("log-opts", "timestamp,ip", "Comma-separated list of log options (timestamp|ip)") - - /* File system */ - fileMonitorFreq := flag.Duration("file-monitor-freq", time.Second*60, "Change file monitor frequency.") - - /* Cache settings */ - cacheSize := flag.Int("cache-size", 50, "Change file cache size, measured in file count.") - cacheFileSizeMax := flag.Float64("cache-file-max", 0.5, "Change maximum file size to be cached (in megabytes).") - cacheDisabled := flag.Bool("disable-cache", false, "Disable file caching.") - - /* Content settings */ - pageWidth := flag.Int("page-width", 80, "Change page width used when formatting output.") -// charSet := flag.String("charset", "", "Change default output charset.") - charSet := "utf-8" - - footerText := flag.String("footer", " Gophor, a Gopher server in Go.", "Change gophermap footer text (Unix new-line separated lines).") - footerSeparator := flag.Bool("no-footer-separator", false, "Disable footer line separator.") - - /* Regex */ - restrictedFiles := flag.String("restrict-files", "", "New-line separated list of regex statements restricting accessible files.") - fileRemaps := flag.String("file-remap", "", "New-line separated list of file remappings of format: /virtual/relative/path -> /actual/relative/path") - - /* User supplied caps.txt information */ - serverDescription := flag.String("description", "Gophor, a Gopher server in Go.", "Change server description in generated caps.txt.") - serverAdmin := flag.String("admin-email", "", "Change admin email in generated caps.txt.") - serverGeoloc := flag.String("geoloc", "", "Change server gelocation string in generated caps.txt.") - - /* Exec settings */ - disableCgi := flag.Bool("disable-cgi", false, "Disable CGI and all executable support.") - httpCompatCgi := flag.Bool("http-compat-cgi", false, "Enable HTTP CGI script compatibility (will strip HTTP headers).") - httpHeaderBuf := flag.Int("http-header-buf", 4096, "Change max CGI read count to look for and strip HTTP headers before sending raw (bytes).") - safeExecPath := flag.String("safe-path", "/usr/bin:/bin", "Set safe PATH variable to be used when executing CGI scripts, gophermaps and inline shell commands.") - maxExecRunTime := flag.Duration("max-exec-time", time.Second*3, "Change max executable CGI, gophermap and inline shell command runtime.") - - /* Buffer sizes */ - socketWriteBuf := flag.Int("socket-write-buf", 4096, "Change socket write buffer size (bytes).") - socketReadBuf := flag.Int("socket-read-buf", 256, "Change socket read buffer size (bytes).") - socketReadMax := flag.Int("socket-read-max", 8, "Change socket read count max (integer multiplier socket-read-buf-max)") - fileReadBuf := flag.Int("file-read-buf", 4096, "Change file read buffer size (bytes).") - - /* Socket deadliens */ - socketReadTimeout := flag.Duration("socket-read-timeout", time.Second*5, "Change socket read deadline (timeout).") - socketWriteTimeout := flag.Duration("socket-write-timeout", time.Second*30, "Change socket write deadline (timeout).") - - /* Version string */ - version := flag.Bool("version", false, "Print version information.") - - /* Parse parse parse!! */ - flag.Parse() - if *version { - printVersionExit() - } - - /* If hostname is nil we set it to bind-addr */ - if *serverHostname == "" { - /* If both are blank that ain't too helpful */ - if *serverBindAddr == "" { - log.Fatalf("Cannot have both -bind-addr and -hostname as empty!\n") - } else { - *serverHostname = *serverBindAddr - } - } - - /* Setup the server configuration instance and enter as much as we can right now */ - Config = new(ServerConfig) - - /* Set misc content settings */ - Config.PageWidth = *pageWidth - - /* Setup various buffer sizes */ - Config.SocketWriteBufSize = *socketWriteBuf - Config.SocketReadBufSize = *socketReadBuf - Config.SocketReadMax = *socketReadBuf * *socketReadMax - Config.FileReadBufSize = *fileReadBuf - - /* Setup socket deadlines */ - Config.SocketReadDeadline = *socketReadTimeout - Config.SocketWriteDeadline = *socketWriteTimeout - - /* Have to be set AFTER page width variable set */ - Config.FooterText = formatGophermapFooter(*footerText, !*footerSeparator) - - /* Setup Gophor logging system */ - Config.SysLog, Config.AccLog = setupLoggers(*logOutput, *logOpts, *systemLogPath, *accessLogPath) - - /* Set CGI support status */ - if *disableCgi { - Config.SysLog.Info("", "CGI support disabled\n") - Config.CgiEnabled = false - } else { - /* Enable CGI */ - Config.SysLog.Info("", "CGI support enabled\n") - Config.CgiEnabled = true - - if *httpCompatCgi { - Config.SysLog.Info("", "Enabling HTTP CGI script compatibility\n") - executeCgi = executeCgiStripHttp - - /* Specific to CGI buffer */ - Config.SysLog.Info("", "Max CGI HTTP header read-ahead: %d bytes\n", *httpHeaderBuf) - Config.SkipPrefixBufSize = *httpHeaderBuf - } else { - executeCgi = executeCgiNoHttp - } - - /* Set safe executable path and setup environments */ - Config.SysLog.Info("", "Setting safe executable path: %s\n", *safeExecPath) - Config.Env = setupExecEnviron(*safeExecPath) - Config.CgiEnv = setupInitialCgiEnviron(*safeExecPath, charSet) - - /* Set executable watchdog */ - Config.SysLog.Info("", "Max executable time: %s\n", *maxExecRunTime) - Config.MaxExecRunTime = *maxExecRunTime - } - - /* If running as root, get ready to drop privileges */ - if syscall.Getuid() == 0 || syscall.Getgid() == 0 { - log.Fatalf("", "Gophor does not support running as root!\n") - } - - /* Enter server dir */ - enterServerDir(*serverRoot) - Config.SysLog.Info("", "Entered server directory: %s\n", *serverRoot) - - /* Setup listeners */ - listeners := make([]*GophorListener, 0) - - /* If requested, setup unencrypted listener */ - if *serverPort != 0 { - /* If no forward port set, just use regular */ - if *serverFwdPort == 0 { - *serverFwdPort = *serverPort - } - - l, err := BeginGophorListen(*serverBindAddr, *serverHostname, strconv.Itoa(*serverPort), strconv.Itoa(*serverFwdPort), *serverRoot) - if err != nil { - log.Fatalf("Error setting up (unencrypted) listener: %s\n", err.Error()) - } - listeners = append(listeners, l) - } else { - log.Fatalf("No valid port to listen on\n") - } - - /* Setup file cache */ - Config.FileSystem = new(FileSystem) - - /* Check if cache requested disabled */ - if !*cacheDisabled { - /* Init file cache */ - Config.FileSystem.Init(*cacheSize, *cacheFileSizeMax) - - /* Before file monitor or any kind of new goroutines started, - * check if we need to cache generated policy files - */ - cachePolicyFiles(*serverRoot, *serverDescription, *serverAdmin, *serverGeoloc) - - /* Start file cache freshness checker */ - startFileMonitor(*fileMonitorFreq) - Config.SysLog.Info("", "File caching enabled with: maxcount=%d maxsize=%.3fMB checkfreq=%s\n", *cacheSize, *cacheFileSizeMax, *fileMonitorFreq) - } else { - /* File caching disabled, init with zero max size so nothing gets cached */ - Config.FileSystem.Init(2, 0) - Config.SysLog.Info("", "File caching disabled\n") - - /* Safe to cache policy files now */ - cachePolicyFiles(*serverRoot, *serverDescription, *serverAdmin, *serverGeoloc) - } - - /* Setup file restrictions and remappings */ - Config.FileSystem.Restricted = compileUserRestrictedRegex(*restrictedFiles) - Config.FileSystem.Remaps = compileUserRemapRegex(*fileRemaps) - - /* Precompile some helpful regex */ - Config.RgxGophermap = compileGophermapCheckRegex() - Config.RgxCgiBin = compileCgiBinCheckRegex() - - /* Return the created listeners slice :) */ - return listeners -} - -func enterServerDir(path string) { - err := syscall.Chdir(path) - if err != nil { - log.Fatalf("Error changing dir to server root %s: %s\n", path, err.Error()) - } -} diff --git a/html.go b/html.go deleted file mode 100644 index 85b63d8..0000000 --- a/html.go +++ /dev/null @@ -1,22 +0,0 @@ -package main - -/* Function does, as function is named */ -func generateHtmlRedirect(url string) []byte { - content := - "\n"+ - "\n"+ - ""+ - "\n"+ - "\n"+ - "You are following an external link to a web site.\n"+ - "You will be automatically taken to the site shortly.\n"+ - "If you do not get sent there, please click here to go to the web site.\n"+ - "

\n"+ - "The URL linked is "+url+"\n"+ - "

\n"+ - "Thanks for using Gophor!\n"+ - "\n"+ - "\n" - - return []byte(content) -} diff --git a/http.go b/http.go deleted file mode 100644 index 8706cc4..0000000 --- a/http.go +++ /dev/null @@ -1,209 +0,0 @@ -package main - -import ( - "io" - "bytes" -) - -type HttpStripWriter struct { - /* Wrapper to io.Writer that reads a predetermined amount into a buffer - * then parses the buffer for valid HTTP headers and status code, deciding - * whether to strip these headers or returning with an HTTP status code. - */ - Writer io.Writer - SkipBuffer []byte - SkipIndex int - Err *GophorError - - /* We set underlying write function with a variable, so that each call - * to .Write() doesn't have to perform a check every time whether we need - * to keep checking for headers to skip. - */ - WriteFunc func([]byte) (int, error) -} - -func NewHttpStripWriter(writer io.Writer) *HttpStripWriter { - w := &HttpStripWriter{} - w.Writer = writer - w.WriteFunc = w.WriteCheckForHeaders - w.SkipBuffer = make([]byte, Config.SkipPrefixBufSize) - w.SkipIndex = 0 - return w -} - -func (w *HttpStripWriter) Size() int { - /* Size of the skip buffer */ - return len(w.SkipBuffer) -} - -func (w *HttpStripWriter) Available() int { - /* How much space have we got left in the skip buffer */ - return w.Size() - w.SkipIndex -} - -func (w *HttpStripWriter) AddToSkipBuffer(data []byte) int { - /* Figure out how much data we need to add */ - toAdd := w.Available() - if len(data) < toAdd { - toAdd = len(data) - } - - /* Add the data to the skip buffer! */ - copy(w.SkipBuffer[w.SkipIndex:], data[:toAdd]) - w.SkipIndex += toAdd - return toAdd -} - -func (w *HttpStripWriter) ParseHttpHeaderSection() (bool, bool) { - /* Check if this is a valid HTTP header section and determine from status if we should continue */ - validHeaderSection, shouldContinue := false, true - for _, header := range bytes.Split(w.SkipBuffer, []byte(DOSLineEnd)) { - header = bytes.ToLower(header) - - if bytes.Contains(header, []byte("content-type: ")) { - /* This whole header section is now _valid_ */ - validHeaderSection = true - } else if bytes.Contains(header, []byte("status: ")) { - /* Try parse status code */ - statusStr := string(bytes.Split(bytes.TrimPrefix(header, []byte("status: ")), []byte(" "))[0]) - - if statusStr == "200" { - /* We ignore this */ - continue - } - - /* Any other values indicate error, we should not continue writing */ - shouldContinue = false - - /* Try parse error code */ - errorCode := CgiStatusUnknownErr - switch statusStr { - case "400": - errorCode = CgiStatus400Err - case "401": - errorCode = CgiStatus401Err - case "403": - errorCode = CgiStatus403Err - case "404": - errorCode = CgiStatus404Err - case "408": - errorCode = CgiStatus408Err - case "410": - errorCode = CgiStatus410Err - case "500": - errorCode = CgiStatus500Err - case "501": - errorCode = CgiStatus501Err - case "503": - errorCode = CgiStatus503Err - } - - /* Set struct error */ - w.Err = &GophorError{ errorCode, nil } - } - } - - return validHeaderSection, shouldContinue -} - -func (w *HttpStripWriter) WriteSkipBuffer() (bool, error) { - defer func() { - w.SkipIndex = 0 - }() - - /* First try parse the headers, determine what to do next */ - validHeaders, shouldContinue := w.ParseHttpHeaderSection() - - if validHeaders { - /* Valid headers, we don't bother writing. Return whether - * shouldContinue whatever value that may be. - */ - return shouldContinue, nil - } - - /* Default is to write skip buffer contents. shouldContinue only - * means something as long as we have valid headers. - */ - _, err := w.Writer.Write(w.SkipBuffer[:w.SkipIndex]) - return true, err -} - -func (w *HttpStripWriter) FinishUp() *GophorError { - /* If SkipBuffer still has contents, in case of data written being less - * than w.Size() --> check this data for HTTP headers to strip, parse - * any status codes and write this content with underlying writer if - * necessary. - */ - if w.SkipIndex > 0 { - w.WriteSkipBuffer() - } - - /* Return HttpStripWriter error code if set */ - return w.Err -} - - -func (w *HttpStripWriter) Write(data []byte) (int, error) { - /* Write using whatever write function is currently set */ - return w.WriteFunc(data) -} - -func (w *HttpStripWriter) WriteRegular(data []byte) (int, error) { - /* Regular write function */ - return w.Writer.Write(data) -} - -func (w *HttpStripWriter) WriteCheckForHeaders(data []byte) (int, error) { - split := bytes.Split(data, []byte(DOSLineEnd+DOSLineEnd)) - if len(split) == 1 { - /* Try add these to skip buffer */ - added := w.AddToSkipBuffer(data) - - if added < len(data) { - defer func() { - /* Having written skipbuffer after this if clause, set write to regular */ - w.WriteFunc = w.WriteRegular - }() - - doContinue, err := w.WriteSkipBuffer() - if !doContinue { - return len(data), io.EOF - } else if err != nil { - return added, err - } - - /* Write remaining data not added to skip buffer */ - count, err := w.Writer.Write(data[added:]) - if err != nil { - return added+count, err - } - } - - return len(data), nil - } else { - defer func() { - /* No use for skip buffer after this clause, set write to regular */ - w.WriteFunc = w.WriteRegular - w.SkipIndex = 0 - }() - - /* Try add what we can to skip buffer */ - added := w.AddToSkipBuffer(append(split[0], []byte(DOSLineEnd+DOSLineEnd)...)) - - /* Write skip buffer data if necessary, check if we should continue */ - doContinue, err := w.WriteSkipBuffer() - if !doContinue { - return len(data), io.EOF - } else if err != nil { - return added, err - } - - /* Write remaining data not added to skip buffer */ - count, err := w.Writer.Write(data[added:]) - if err != nil { - return added+count, err - } - - return len(data), nil - } -} diff --git a/logger.go b/logger.go deleted file mode 100644 index 5410d4e..0000000 --- a/logger.go +++ /dev/null @@ -1,157 +0,0 @@ -package main - -import ( - "log" - "os" - "strings" -) - -const ( - /* Prefixes */ - LogPrefixInfo = ": I :: " - LogPrefixError = ": E :: " - LogPrefixFatal = ": F :: " - - /* Log output types */ - LogDisabled = "disable" - LogToStderr = "stderr" - LogToFile = "file" - - /* Log options */ - LogTimestamps = "timestamp" - LogIps = "ip" -) - -/* Defines a simple logger interface */ -type LoggerInterface interface { - Info(string, string, ...interface{}) - Error(string, string, ...interface{}) - Fatal(string, string, ...interface{}) -} - -/* Logger interface definition that does jack-shit */ -type NullLogger struct {} -func (l *NullLogger) Info(prefix, format string, args ...interface{}) {} -func (l *NullLogger) Error(prefix, format string, args ...interface{}) {} -func (l *NullLogger) Fatal(prefix, format string, args ...interface{}) {} - -/* A basic logger implemention */ -type Logger struct { - Logger *log.Logger -} - -func (l *Logger) Info(prefix, format string, args ...interface{}) { - l.Logger.Printf(LogPrefixInfo+prefix+format, args...) -} - -func (l *Logger) Error(prefix, format string, args ...interface{}) { - l.Logger.Printf(LogPrefixError+prefix+format, args...) -} - -func (l *Logger) Fatal(prefix, format string, args ...interface{}) { - l.Logger.Fatalf(LogPrefixFatal+prefix+format, args...) -} - -/* Logger implementation that ignores the prefix (e.g. when not printing IPs) */ -type LoggerNoPrefix struct { - Logger *log.Logger -} - -func (l *LoggerNoPrefix) Info(prefix, format string, args ...interface{}) { - /* Ignore the prefix */ - l.Logger.Printf(LogPrefixInfo+format, args...) -} - -func (l *LoggerNoPrefix) Error(prefix, format string, args ...interface{}) { - /* Ignore the prefix */ - l.Logger.Printf(LogPrefixError+format, args...) -} - -func (l *LoggerNoPrefix) Fatal(prefix, format string, args ...interface{}) { - /* Ignore the prefix */ - l.Logger.Fatalf(LogPrefixFatal+format, args...) -} - -/* Setup global logger */ -func setupGlobalLogger() { - log.SetFlags(0) - log.SetOutput(os.Stderr) -} - -/* Setup the system and access logger interfaces according to supplied output options and logger options */ -func setupLoggers(logOutput, logOpts, systemLogPath, accessLogPath string) (LoggerInterface, LoggerInterface) { - /* Parse the logger options */ - logIps := false - logFlags := 0 - for _, opt := range strings.Split(logOpts, ",") { - switch opt { - case "": - continue - - case LogTimestamps: - logFlags = log.LstdFlags - - case LogIps: - logIps = true - - default: - log.Fatalf("Unrecognized log opt: %s\n") - } - } - - /* Setup the loggers according to requested logging output */ - switch logOutput { - case "": - /* Assume empty means stderr */ - fallthrough - - case LogToStderr: - /* Return two separate stderr loggers */ - sysLogger := &LoggerNoPrefix{ NewLoggerToStderr(logFlags) } - if logIps { - return sysLogger, &Logger{ NewLoggerToStderr(logFlags) } - } else { - return sysLogger, &LoggerNoPrefix{ NewLoggerToStderr(logFlags) } - } - - case LogDisabled: - /* Return two pointers to same null logger */ - nullLogger := &NullLogger{} - return nullLogger, nullLogger - - case LogToFile: - /* Return two separate file loggers */ - sysLogger := &Logger{ NewLoggerToFile(systemLogPath, logFlags) } - if logIps { - return sysLogger, &Logger{ NewLoggerToFile(accessLogPath, logFlags) } - } else { - return sysLogger, &LoggerNoPrefix{ NewLoggerToFile(accessLogPath, logFlags) } - } - - default: - log.Fatalf("Unrecognised log output type: %s\n", logOutput) - return nil, nil - } - -} - -/* Helper function to create new standard log.Logger to stderr */ -func NewLoggerToStderr(logFlags int) *log.Logger { - return log.New(os.Stderr, "", logFlags) -} - -/* Helper function to create new standard log.Logger to file */ -func NewLoggerToFile(path string, logFlags int) *log.Logger { - writer, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) - if err != nil { - log.Fatalf("Failed to create logger to file %s: %s\n", path, err.Error()) - } - return log.New(writer, "", logFlags) -} - -/* Set the default logger flags before printing version */ -func printVersionExit() { - log.SetFlags(0) - log.Printf("%s\n", GophorVersion) - os.Exit(0) -} diff --git a/main_gopher.go b/main_gopher.go new file mode 100644 index 0000000..b7713f7 --- /dev/null +++ b/main_gopher.go @@ -0,0 +1,9 @@ +package main + +import ( + "gophor/gopher" +) + +func main() { + gopher.Run() +} diff --git a/parse.go b/parse.go deleted file mode 100644 index 0390bec..0000000 --- a/parse.go +++ /dev/null @@ -1,154 +0,0 @@ -package main - -import ( - "strings" - "net/url" -) - -/* Parse a request string into a path and parameters string */ -func parseGopherUrl(request string) (*GopherUrl, *GophorError) { - if strings.Contains(request, "#") || // we don't support fragments - strings.HasPrefix(request, "GET ") { // we don't support HTTP requests - return nil, &GophorError{ InvalidRequestErr, nil } - } - - /* Check if string contains any ASCII control byte */ - for i := 0; i < len(request); i += 1 { - if request[i] < ' ' || request[i] == 0x7f { - return nil, &GophorError{ InvalidRequestErr, nil } - } - } - - /* Split into 2 substrings by '?'. Url path and query */ - split := strings.SplitN(request, "?", 2) - - /* Unescape path */ - path, err := url.PathUnescape(split[0]) - if err != nil { - return nil, &GophorError{ InvalidRequestErr, nil } - } - - /* Return GopherUrl based on this split request */ - if len(split) == 1 { - return &GopherUrl{ path, "" }, nil - } else { - return &GopherUrl{ path, split[1] }, nil - } -} - -/* Parse line type from contents */ -func parseLineType(line string) ItemType { - lineLen := len(line) - - if lineLen == 0 { - return TypeInfoNotStated - } else if lineLen == 1 { - /* The only accepted types for a length 1 line */ - switch ItemType(line[0]) { - case TypeEnd: - return TypeEnd - case TypeEndBeginList: - return TypeEndBeginList - case TypeComment: - return TypeComment - case TypeInfo: - return TypeInfo - case TypeTitle: - return TypeTitle - default: - return TypeUnknown - } - } else if !strings.Contains(line, string(Tab)) { - /* The only accepted types for a line with no tabs */ - switch ItemType(line[0]) { - case TypeComment: - return TypeComment - case TypeTitle: - return TypeTitle - case TypeInfo: - return TypeInfo - case TypeHiddenFile: - return TypeHiddenFile - case TypeSubGophermap: - return TypeSubGophermap - default: - return TypeInfoNotStated - } - } - - return ItemType(line[0]) -} - -/* Parses a line in a gophermap into a new request object */ -func parseLineRequestString(requestPath *RequestPath, lineStr string) (*Request, *GophorError) { - if strings.HasPrefix(lineStr, "/") { - /* Assume is absolute (well, seeing server root as '/') */ - if withinCgiBin(lineStr[1:]) { - /* CGI script, parse request path and parameters */ - url, gophorErr := parseGopherUrl(lineStr[1:]) - if gophorErr != nil { - return nil, gophorErr - } else { - return &Request{ NewRequestPath(requestPath.RootDir(), url.Path), url.Parameters }, nil - } - } else { - /* Regular file, no more parsing */ - return &Request{ NewRequestPath(requestPath.RootDir(), lineStr[1:]), "" }, nil - } - } else { - /* Assume relative to current directory */ - if withinCgiBin(lineStr) && requestPath.Relative() == "" { - /* If begins with cgi-bin and is at root dir, parse as cgi-bin */ - url, gophorErr := parseGopherUrl(lineStr) - if gophorErr != nil { - return nil, gophorErr - } else { - return &Request{ NewRequestPath(requestPath.RootDir(), url.Path), url.Parameters }, nil - } - } else { - /* Regular file, no more parsing */ - return &Request{ NewRequestPath(requestPath.RootDir(), requestPath.JoinCurDir(lineStr)), "" }, nil - } - } -} - -/* Split a string according to a rune, that supports delimiting with '\' */ -func splitStringByRune(str string, r rune) []string { - ret := make([]string, 0) - buf := "" - delim := false - for _, c := range str { - switch c { - case r: - if !delim { - ret = append(ret, buf) - buf = "" - } else { - buf += string(c) - delim = false - } - - case '\\': - if !delim { - delim = true - } else { - buf += string(c) - delim = false - } - - default: - if !delim { - buf += string(c) - } else { - buf += "\\"+string(c) - delim = false - } - } - } - - if len(buf) > 0 || len(ret) == 0 { - ret = append(ret, buf) - } - - return ret -} diff --git a/policy.go b/policy.go deleted file mode 100644 index de7ad0c..0000000 --- a/policy.go +++ /dev/null @@ -1,96 +0,0 @@ -package main - -import ( - "os" - "path" - "sync" -) - -const ( - /* Filename constants */ - CapsTxtStr = "caps.txt" - RobotsTxtStr = "robots.txt" -) - -func cachePolicyFiles(rootDir, description, admin, geoloc string) { - /* See if caps txt exists, if not generate */ - _, err := os.Stat(path.Join(rootDir, CapsTxtStr)) - if err != nil { - /* We need to generate the caps txt and manually load into cache */ - content := generateCapsTxt(description, admin, geoloc) - - /* Create new file object from generated file contents */ - fileContents := &GeneratedFileContents{ content } - file := &File{ fileContents, sync.RWMutex{}, true, 0 } - - /* Trigger a load contents just to set it as fresh etc */ - file.CacheContents() - - /* No need to worry about mutexes here, no other goroutines running yet */ - Config.FileSystem.CacheMap.Put(rootDir+"/"+CapsTxtStr, file) - Config.SysLog.Info("", "Generated policy file: %s\n", rootDir+"/"+CapsTxtStr) - } - - /* See if robots txt exists, if not generate */ - _, err = os.Stat(rootDir+"/"+RobotsTxtStr) - if err != nil { - /* We need to generate the robots txt and manually load into cache */ - content := generateRobotsTxt() - - /* Create new file object from generated file contents */ - fileContents := &GeneratedFileContents{ content } - file := &File{ fileContents, sync.RWMutex{}, true, 0 } - - /* Trigger a load contents just to set it as fresh etc */ - file.CacheContents() - - /* No need to worry about mutexes here, no other goroutines running yet */ - Config.FileSystem.CacheMap.Put(rootDir+"/"+RobotsTxtStr, file) - Config.SysLog.Info("", "Generated policy file: %s\n", rootDir+"/"+RobotsTxtStr) - } -} - -func generatePolicyHeader(filename string) string { - text := "# This is an automatically generated"+DOSLineEnd - text += "# server policy file: "+filename+DOSLineEnd - text += "#"+DOSLineEnd - text += "# Eat the rich ~GophorDev"+DOSLineEnd - return text -} - -func generateCapsTxt(description, admin, geoloc string) []byte { - text := "CAPS"+DOSLineEnd - text += DOSLineEnd - text += generatePolicyHeader(CapsTxtStr) - text += DOSLineEnd - text += "CapsVersion=1"+DOSLineEnd - text += "ExpireCapsAfter=1800"+DOSLineEnd - text += DOSLineEnd - text += "PathDelimeter=/"+DOSLineEnd - text += "PathIdentity=."+DOSLineEnd - text += "PathParent=.."+DOSLineEnd - text += "PathParentDouble=FALSE"+DOSLineEnd - text += "PathEscapeCharacter=\\"+DOSLineEnd - text += "PathKeepPreDelimeter=FALSE"+DOSLineEnd - text += DOSLineEnd - text += "ServerSoftware=Gophor"+DOSLineEnd - text += "ServerSoftwareVersion="+GophorVersion+DOSLineEnd - text += "ServerDescription="+description+DOSLineEnd - text += "ServerGeolocationString="+geoloc+DOSLineEnd -// text += "ServerDefaultEncoding=ascii"+DOSLineEnd - text += DOSLineEnd - text += "ServerAdmin="+admin+DOSLineEnd - return []byte(text) -} - -func generateRobotsTxt() []byte { - text := generatePolicyHeader(RobotsTxtStr) - text += DOSLineEnd - text += "Usage-agent: *"+DOSLineEnd - text += "Disallow: *"+DOSLineEnd - text += DOSLineEnd - text += "Crawl-delay: 99999"+DOSLineEnd - text += DOSLineEnd - text += "# This server does not support scraping"+DOSLineEnd - return []byte(text) -} diff --git a/regex.go b/regex.go deleted file mode 100644 index d95385e..0000000 --- a/regex.go +++ /dev/null @@ -1,94 +0,0 @@ -package main - -import ( - "regexp" - "strings" - "log" -) - -const ( - FileRemapSeparatorStr = " -> " -) - -type FileRemap struct { - Regex *regexp.Regexp - Template string -} - -/* Pre-compile gophermap file string regex */ -func compileGophermapCheckRegex() *regexp.Regexp { - return regexp.MustCompile(`^(|.+/|.+\.)gophermap$`) -} - -/* Pre-compile cgi-bin path string regex */ -func compileCgiBinCheckRegex() *regexp.Regexp { - return regexp.MustCompile(`^cgi-bin(|/.*)$`) -} - -/* Compile a user supplied new line separated list of regex statements */ -func compileUserRestrictedRegex(restrictions string) []*regexp.Regexp { - /* Return slice */ - restrictedRegex := make([]*regexp.Regexp, 0) - - /* Split the user supplied regex statements by new line */ - for _, expr := range strings.Split(restrictions, "\n") { - /* Empty expression, skip */ - if len(expr) == 0 { - continue - } - - /* Try compile regex */ - regex, err := regexp.Compile(expr) - if err != nil { - log.Fatalf("Failed compiling user supplied regex: %s\n", expr) - } - - /* Append restricted */ - restrictedRegex = append(restrictedRegex, regex) - Config.SysLog.Info("", "Compiled restricted: %s\n", expr) - } - - return restrictedRegex -} - -/* Compile a user supplied new line separated list of file remap regex statements */ -func compileUserRemapRegex(remaps string) []*FileRemap { - /* Return slice */ - fileRemaps := make([]*FileRemap, 0) - - /* Split the user supplied regex statements by new line */ - for _, expr := range strings.Split(remaps, "\n") { - /* Empty expression, skip */ - if len(expr) == 0 { - continue - } - - /* Split into alias and remap string (MUST BE LENGTH 2) */ - split := strings.Split(expr, FileRemapSeparatorStr) - if len(split) != 2 { - continue - } - - /* Try compile regex */ - regex, err := regexp.Compile("(?m)"+strings.TrimPrefix(split[0], "/")+"$") - if err != nil { - log.Fatalf("Failed compiling user supplied regex: %s\n", expr) - } - - /* Append file remapper */ - fileRemaps = append(fileRemaps, &FileRemap{ regex, strings.TrimPrefix(split[1], "/") }) - Config.SysLog.Info("", "Compiled remap: %s\n", expr) - } - - return fileRemaps -} - -/* Check if file path is gophermap */ -func isGophermap(path string) bool { - return Config.RgxGophermap.MatchString(path) -} - -/* Check if file path within cgi-bin */ -func withinCgiBin(path string) bool { - return Config.RgxCgiBin.MatchString(path) -} diff --git a/request.go b/request.go deleted file mode 100644 index b56012d..0000000 --- a/request.go +++ /dev/null @@ -1,130 +0,0 @@ -package main - -import ( - "path" - "strings" -) - -type RequestPath struct { - /* Path structure to allow hosts at - * different roots while maintaining relative - * and absolute path names for filesystem reading - */ - - Root string - Rel string - Abs string - Select string -} - -func NewRequestPath(rootDir, relPath string) *RequestPath { - return &RequestPath{ rootDir, relPath, path.Join(rootDir, strings.TrimSuffix(relPath, "/")), relPath } -} - -func (rp *RequestPath) RemapPath(newPath string) *RequestPath { - requestPath := NewRequestPath(rp.RootDir(), sanitizeRawPath(rp.RootDir(), newPath)) - requestPath.Select = rp.Relative() - return requestPath -} - -func (rp *RequestPath) RootDir() string { - return rp.Root -} - -func (rp *RequestPath) Relative() string { - return rp.Rel -} - -func (rp *RequestPath) Absolute() string { - return rp.Abs -} - -func (rp *RequestPath) Selector() string { - if rp.Select == "." { - return "/" - } else { - return "/"+rp.Select - } -} - -func (rp *RequestPath) JoinRel(extPath string) string { - return path.Join(rp.Relative(), extPath) -} - -func (rp *RequestPath) JoinAbs(extPath string) string { - return path.Join(rp.Absolute(), extPath) -} - -func (rp *RequestPath) JoinSelector(extPath string) string { - return path.Join(rp.Selector(), extPath) -} - -func (rp *RequestPath) HasAbsPrefix(prefix string) bool { - return strings.HasPrefix(rp.Absolute(), prefix) -} - -func (rp *RequestPath) HasRelPrefix(prefix string) bool { - return strings.HasPrefix(rp.Relative(), prefix) -} - -func (rp *RequestPath) HasRelSuffix(suffix string) bool { - return strings.HasSuffix(rp.Relative(), suffix) -} - -func (rp *RequestPath) HasAbsSuffix(suffix string) bool { - return strings.HasSuffix(rp.Absolute(), suffix) -} - -func (rp *RequestPath) TrimRelSuffix(suffix string) string { - return strings.TrimSuffix(strings.TrimSuffix(rp.Relative(), suffix), "/") -} - -func (rp *RequestPath) TrimAbsSuffix(suffix string) string { - return strings.TrimSuffix(strings.TrimSuffix(rp.Absolute(), suffix), "/") -} - -func (rp *RequestPath) JoinCurDir(extPath string) string { - return path.Join(path.Dir(rp.Relative()), extPath) -} - -func (rp *RequestPath) JoinRootDir(extPath string) string { - return path.Join(rp.RootDir(), extPath) -} - -type Request struct { - /* Holds onto a request path to the filesystem and - * a string slice of parsed parameters (usually nil - * or length 1) - */ - - Path *RequestPath - Parameters string -} - -func NewSanitizedRequest(rootDir string, url *GopherUrl) *Request { - return &Request{ - NewRequestPath( - rootDir, - sanitizeRawPath(rootDir, url.Path), - ), - url.Parameters, - } -} - -/* Sanitize a request path string */ -func sanitizeRawPath(rootDir, relPath string) string { - /* Start with a clean :) */ - relPath = path.Clean(relPath) - - if path.IsAbs(relPath) { - /* Is absolute. Try trimming root and leading '/' */ - relPath = strings.TrimPrefix(strings.TrimPrefix(relPath, rootDir), "/") - } else { - /* Is relative. If back dir traversal, give them root */ - if strings.HasPrefix(relPath, "..") { - relPath = "" - } - } - - return relPath -} diff --git a/responder.go b/responder.go deleted file mode 100644 index acde737..0000000 --- a/responder.go +++ /dev/null @@ -1,54 +0,0 @@ -package main - -import ( - "io" -) - -type Responder struct { - Conn *BufferedDeadlineConn - Host *ConnHost - Client *ConnClient - Request *Request -} - -func NewResponder(conn *BufferedDeadlineConn, host *ConnHost, client *ConnClient, request *Request) *Responder { - return &Responder{ conn, host, client, request } -} - -func (r *Responder) AccessLogInfo(format string, args ...interface{}) { - Config.AccLog.Info("("+r.Client.Ip()+") ", format, args...) -} - -func (r *Responder) AccessLogError(format string, args ...interface{}) { - Config.AccLog.Error("("+r.Client.Ip()+") ", format, args...) -} - -func (r *Responder) Write(b []byte) (int, error) { - return r.Conn.Write(b) -} - -func (r *Responder) WriteData(data []byte) *GophorError { - err := r.Conn.WriteData(data) - if err != nil { - return &GophorError{ SocketWriteErr, err } - } - return nil -} - -func (r *Responder) WriteRaw(reader io.Reader) *GophorError { - err := r.Conn.WriteRaw(reader) - if err != nil { - return &GophorError{ SocketWriteRawErr, err } - } - return nil -} - -func (r *Responder) CloneWithRequest(request *Request) *Responder { - /* Create new copy of Responder only with request differring */ - return &Responder{ - r.Conn, - r.Host, - r.Client, - request, - } -} diff --git a/worker.go b/worker.go deleted file mode 100644 index 4bcb2ac..0000000 --- a/worker.go +++ /dev/null @@ -1,71 +0,0 @@ -package main - -import ( - "strings" -) - -type Worker struct { - Conn *BufferedDeadlineConn - Host *ConnHost - Client *ConnClient - RootDir string -} - -func (worker *Worker) Serve() { - defer worker.Conn.Close() - - line, err := worker.Conn.ReadLine() - if err != nil { - Config.SysLog.Error("", "Error reading from socket port %s: %s\n", worker.Host.Port(), err.Error()) - return - } - - /* Drop up to first tab */ - received := strings.Split(string(line), Tab)[0] - - /* Handle URL request if presented */ - lenBefore := len(received) - received = strings.TrimPrefix(received, "URL:") - switch len(received) { - case lenBefore-4: - /* Send an HTML redirect to supplied URL */ - Config.AccLog.Info("("+worker.Client.Ip()+") ", "Redirecting to %s\n", received) - worker.Conn.Write(generateHtmlRedirect(received)) - return - default: - /* Do nothing */ - } - - /* Create GopherUrl object from request string */ - url, gophorErr := parseGopherUrl(received) - if gophorErr == nil { - /* Create new request from url object */ - request := NewSanitizedRequest(worker.RootDir, url) - - /* Create new responder from request */ - responder := NewResponder(worker.Conn, worker.Host, worker.Client, request) - - /* Handle request with supplied responder */ - gophorErr = Config.FileSystem.HandleRequest(responder) - if gophorErr == nil { - /* Log success to access and return! */ - responder.AccessLogInfo("Served: %s\n", request.Path.Absolute()) - return - } else { - /* Log failure to access */ - responder.AccessLogError("Failed to serve: %s\n", request.Path.Absolute()) - } - } - - /* Log serve failure to error to system */ - Config.SysLog.Error("", gophorErr.Error()) - - /* Generate response bytes from error code */ - errResponse := generateGopherErrorResponseFromCode(gophorErr.Code) - - /* If we got response bytes to send? SEND 'EM! */ - if errResponse != nil { - /* No gods. No masters. We don't care about error checking here */ - worker.Conn.WriteData(errResponse) - } -}