esgd/esgd.py

280 lines
8.2 KiB
Python
Executable File

#!/usr/bin/env python3
# esgd - exceedingly simple gopher daemon
# Copyright (c) 2021 randomuser/rndusr <randomuser@tilde.club>
# see LICENSE for license information; README for instructions
# This program is free software: you can redistribute it and/or
# modify it under the terms of the GNU Affero General Public
# License as published by the Free Software Foundation, either
# version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be
# useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
# PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General
# Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
from socketserver import TCPServer
from socketserver import BaseRequestHandler
import subprocess
import argparse
import os
import time
import pwd
import grp
class GopherError(BaseException): pass
class RequestError(GopherError): pass
class GopherLine:
def __init__(self, line):
self.item = "i"
self.text = ""
self.location = "null"
self.host = "null.host"
self.port = "0"
self.line = line
self.parse()
def parse(self):
split = self.line.rstrip().split('\t')
location = False
if len(split) == 1:
self.text = split[0]
return
try: self.item = split[0][0]
except IndexError: pass
try: self.text = split[0][1:]
except IndexError: pass
try:
self.location = split[1]
location = True
except IndexError: pass
try: self.host = split[2]
except IndexError:
if location == True: self.host = ghost
try: self.port = split[3]
except IndexError:
if location == True: self.port = str(port)
def render(self):
return "{}{}\t{}\t{}\t{}".format(
self.item,
self.text,
self.location,
self.host,
self.port
)
class Logger:
def __init__(self, file=None):
if file != None: self.fd = open(file, "a+")
else: self.fd = False
def time(self):
return int(time.time())
def write(self, msg):
if self.fd: self.fd.write("{}\n".format(msg))
print("{}".format(msg))
def log(self, msg):
ct = self.time()
self.write("[{}] {}".format(str(ct), msg))
def warn(self, msg):
ct = self.time()
self.write("! [{}] {}".format(str(ct), msg))
def error(self, msg):
ct = self.time()
self.write("!! [{}] {}".format(str(ct), msg))
self.close()
raise SystemExit
def close(self):
if self.fd: self.fd.close()
class GopherServerLogic:
def recieveRequest(self):
data = b""
while data[-2:] != b"\r\n":
data += self.request.recv(1)
decoded = data[:-2].decode("utf-8")
return decoded
def requestParser(self, request):
if ".." in request: raise RequestError
try:
if request[0] == "/": request = request[1:]
except IndexError: pass
request = request.replace("?", "\t").split("\t")
try: return (request[0], request[1])
except IndexError: return (request[0], "")
def returnRelative(self, file):
gph = False
if file == "":
ret = "gophermap"
gph = True
elif os.path.isdir(file):
if file[-1] != "/": ret = file + "/gophermap"
else: ret = file + "gophermap"
gph = True
elif os.path.isfile(file):
ret = file
else: raise RequestError("unreachable state")
return (ret, gph)
def fileSendable(self, file):
return os.access(file, os.F_OK|os.R_OK)
def fileCGI(self, file):
if exempt != None:
for i in exempt:
if i in file: return False
if nocgi:
return False
return os.access(file, os.F_OK|os.R_OK|os.X_OK)
def notFound(self):
self.request.sendall(
b"3error: file not found!\r\n.\r\n")
def invalid(self):
self.request.sendall(
b"3error: selector contains '..'!\r\n.\r\n")
def fileToFileArray(self, file):
fd = open(file, "r")
ret = [i.rstrip() for i in fd.readlines()]
fd.close()
return ret
def sendFileArray(self, fileArray):
[self.request.sendall(
(i.rstrip() + "\r\n").encode("utf-8")
) for i in fileArray]
self.request.sendall(b".\r\n")
def cgi(self, file, query):
env = {}
env["QUERY_STRING"] = query
env["SCRIPT_NAME"] = "/" + file
env["REMOTE_ADDR"] = self.client_address[0]
proc = subprocess.Popen(
[os.getcwd() + "/" + file],
stdout = subprocess.PIPE,
env = env
)
try: out, err = proc.communicate(timeout=10)
except TimeoutExpired:
proc.kill()
out, err = proc.communicate()
return out.decode("utf-8")\
.replace("\r", "")\
.split("\n")
def gopherRenderer(self, fileArray):
gopherlines = []
for i in fileArray:
gopherlines.append(GopherLine(i))
returned = []
for i in gopherlines:
returned.append(i.render())
return returned
def serveFile(self, file, query, gph):
if self.fileCGI(file):
fa = self.cgi(file, query)
elif self.fileSendable(file):
fa = self.fileToFileArray(file)
else:
log.log("selector not found")
self.notFound()
return
if gph: fa = self.gopherRenderer(fa)
self.sendFileArray(fa)
class GopherHandler(BaseRequestHandler,
GopherServerLogic):
def handle(self):
log.log("request from {}".format(
self.client_address[0]))
decoded = self.recieveRequest()
try: parsed = self.requestParser(decoded)
except RequestError:
log.log("request was invalid")
self.invalid()
return
try: file = self.returnRelative(parsed[0])
except RequestError:
log.log("selector not found")
self.notFound()
return
log.log("serving {}".format(file[0]))
self.serveFile(file[0], parsed[1], file[1])
log = Logger()
def parseArgs():
parse = argparse.ArgumentParser()
parse.add_argument("-u", "--user", default="nobody",
help="user to change to on startup")
parse.add_argument("-g", "--group", default="nobody",
help="group to change to on startup")
parse.add_argument("-s", "--host", default="localhost",
help="host to host on")
parse.add_argument("-gs", "--gopherhost", default=None,
help="host defaulted to in links on gophermaps")
parse.add_argument("-p", "--port", default=70, type=int,
help="port to host on")
parse.add_argument("-d", "--dir", default="/var/gopher",
help="directory to host from")
parse.add_argument("-ndx", "--direxc",
action="append", nargs="*",
help="make directory-wide exception for file execution")
parse.add_argument("-nx", "--nocgi", action="store_true",
help="disable execution of cgi scripts")
return parse.parse_args()
if __name__ == "__main__":
args = parseArgs()
log.log("arguments parsed")
switchgrp = grp.getgrnam(args.group).gr_gid
switchusr = pwd.getpwnam(args.user).pw_uid
host = args.host
ghost = args.gopherhost
port = args.port
location = args.dir
exempt = args.direxc
nocgi = args.nocgi
log.log("arguments good")
if ghost == None: ghost = host
try:
with TCPServer((host, port), GopherHandler) as server:
os.setgid(switchgrp)
os.seteuid(switchusr)
log.log("switch user successful")
os.chdir(location)
log.log("change directory successful")
server.serve_forever()
except PermissionError: log.error("invalid permissions")
except OSError: log.error("address already in use")