diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f408e55 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +FROM debian:bullseye-slim +WORKDIR /opt/program +# Set some environment variables. PYTHONUNBUFFERED keeps Python from buffering our standard +# output stream, which means that logs can be delivered to the user quickly. PYTHONDONTWRITEBYTECODE +# keeps Python from writing the .pyc files which are unnecessary in this case. We also update +# PATH so that the serve program is found when the container is invoked. +ENV PYTHONUNBUFFERED=TRUE +ENV PYTHONDONTWRITEBYTECODE=TRUE +ENV PATH="/opt/program:${PATH}" + +# Get basic dependencies +RUN apt-get -y update && apt-get install -y --no-install-recommends \ + build-essential \ + python3-pip \ + python3-dev \ + nginx \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# overwrite the link to system python and upgrade pip +RUN ln -s /usr/bin/python3 /usr/bin/python \ + && pip3 install --upgrade pip + +COPY requirements.txt /tmp/ +RUN pip3 install -r /tmp/requirements.txt && \ + rm -rf /root/.cache + + +# Set up the program in the image +COPY ./ /opt/program +RUN chmod +x /opt/program/serve.py +EXPOSE 9992 +CMD ./serve.py \ No newline at end of file diff --git a/README.md b/README.md index 3d9da46..d9c397d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,20 @@ # mc-status-page -Logs minecraft status to postgres and creates webpage based on current server status using mcstatus. \ No newline at end of file +Logs minecraft status to postgres and creates webpage based on current server status using mcstatus. + +## Usage + +Create `.env` with these keys: +``` +DB_HOST= +DB_NAME= +DB_USER= +DB_PASS= +DB_PROJ= +DB_TABLE= +MINE_IP= +``` + +where `MINE_IP` is the IP Address of your Minecraft server (including port), and the rest correspond to your PostgreSQL database. + +Then `docker-compose up -d` will run the site on port 9992. diff --git a/app.py b/app.py new file mode 100644 index 0000000..505b818 --- /dev/null +++ b/app.py @@ -0,0 +1,95 @@ +"""Flask app to display a table with the result of a postgreSQL query""" + +from flask import Flask, render_template +import pandas as pd +from dotenv import load_dotenv +import os +import psycopg2 +import logging +import sys +import datetime +import warnings + +warnings.filterwarnings("ignore") + +load_dotenv() + +app = Flask(__name__) + +def connect_to_database(args): + + try: + conn = psycopg2.connect( + host=args.dbhost, + database=args.dbname, + user=args.dbuser, + password=args.dbpass, + sslmode='require', + options=f'project={args.project}' + ) + cur = conn.cursor() + except Exception as e: + logging.error('Failed to connect to database: %s', e) + sys.exit(1) + + table_name = vars(args).get('table', 'minecraft') + # if args.clean doesn't exist, it will be False + if vars(args).get('clean', False): + # remove table if it exists + try: + cur.execute(f'DROP TABLE IF EXISTS {table_name}') + conn.commit() + except Exception as e: + logging.error('Failed to drop table: %s', e) + sys.exit(1) + + # Create table if it doesn't exist + try: + cur.execute(f'CREATE TABLE IF NOT EXISTS {table_name} (timestamp TIMESTAMP, server_name TEXT, version TEXT, protocol INT, players INT, max_players INT, latency INT)') + conn.commit() + except Exception as e: + logging.error('Failed to create table: %s', e) + sys.exit(1) + + return conn + + +@app.route("/") +def index(): + # make a fake args object that acts the same way as argparse.Namespace + class Args: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + args = Args( + dbhost=os.environ.get("DB_HOST", "localhost"), + dbname=os.environ.get("DB_NAME", "minecraft"), + dbuser=os.environ.get("DB_USER", "postgres"), + dbpass=os.environ.get("DB_PASS", ""), + project=os.environ.get("DB_PROJ", "default"), + table=os.environ.get("DB_TABLE", "minecraft"), + ) + + conn = connect_to_database(args) + table_name = vars(args).get("table", "minecraft") + df = pd.read_sql( + f"SELECT * FROM {table_name} ORDER BY timestamp DESC LIMIT 100;", conn + ) + online = df["players"].tolist()[0] + df = df[['timestamp', 'players', 'latency']] + # format timestamp + df['timestamp'] = df['timestamp'].dt.strftime('%Y-%m-%d %H:%M:%S') + # if the timestamp is more than 5 minutes old, the server is offline + latest_timestamp = datetime.datetime.strptime(df['timestamp'].tolist()[0], '%Y-%m-%d %H:%M:%S') + if (datetime.datetime.now() - latest_timestamp) > datetime.timedelta(minutes=5): + status = "The server is currently offline. Please try again later." + else: + if online > 0: + status = f"There are currently { online } players online." + else: + status = "There are currently no players online." + return render_template("index.html", data=df.to_html(justify="center"), status=status) + + +if __name__ == "__main__": + app.run(debug=True, host="0.0.0.0", port=9993) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a273df6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +# docker-compose file for serving nginx without SSL + +version: '3.7' + +services: + nginx: + build: . + container_name: mcstatus-server + restart: always + ports: + - 9992:9992 + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf + - ./html:/usr/share/nginx/html + - ./logs:/var/log/nginx + environment: + - TZ=America/Denver + env_file: + - .env + + status: + build: . + container_name: mcstatus + restart: always + environment: + - TZ=America/Denver + env_file: + - ../.env + command: python mc.py + +networks: + default: + external: + name: mcstatus diff --git a/mc.py b/mc.py new file mode 100644 index 0000000..dbba881 --- /dev/null +++ b/mc.py @@ -0,0 +1,137 @@ +""" +Application to ping Minecraft server and write all data to postgresql database. +""" + +import os +import sys +import time +import datetime +import logging +import argparse + +from mcstatus import JavaServer as MinecraftServer +import psycopg2 +from typing import Tuple, Dict + +from dotenv import load_dotenv + +# read environment variables from .env file +load_dotenv() + +# Set up logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s') + +# get DB_HOST, DB_PROJ, DB_USER, DB_PASS, and DB_NAME from environment variables +DB_HOST = os.environ.get('DB_HOST', 'localhost') +DB_NAME = os.environ.get('DB_NAME', 'main') +DB_TABLE = os.environ.get('DB_TABLE', 'minecraft') +DB_PROJ = os.environ.get('DB_PROJ', 'default') +DB_USER = os.environ.get('DB_USER', 'user') +DB_PASS = os.environ.get('DB_PASS', 'password') +IP_ADD = os.environ.get('MINE_IP', 'localhost') + + +def parse_args() -> argparse.Namespace: + # Set up command line arguments + parser = argparse.ArgumentParser(description='Monitor Minecraft server activity and write it to postgresql database.') + parser.add_argument('--dbhost', default=DB_HOST, help='Postgresql database host') + parser.add_argument('--dbname', default=DB_NAME, help='Postgresql database name') + parser.add_argument('--dbuser', default=DB_USER, help='Postgresql database user') + parser.add_argument('--dbpass', default=DB_PASS, help='Postgresql database password') + parser.add_argument('--project', default=DB_PROJ, help='Project name') + parser.add_argument('--table', default=DB_TABLE, help='Table name') + parser.add_argument('--ip', default=IP_ADD, help='IP Address of Minecraft Server') + parser.add_argument('--interval', default=60, type=int, help='Interval in seconds between measurements') + parser.add_argument('--clean', action='store_true', help='Clean database before starting (default: False)') + args = parser.parse_args() + return args + +# Connect to database +def connect_to_database(args): + + try: + conn = psycopg2.connect( + host=args.dbhost, + database=args.dbname, + user=args.dbuser, + password=args.dbpass, + sslmode='require', + options=f'project={args.project}' + ) + cur = conn.cursor() + except Exception as e: + logging.error('Failed to connect to database: %s', e) + sys.exit(1) + + table_name = vars(args).get('table', DB_TABLE) + # if args.clean doesn't exist, it will be False + if vars(args).get('clean', False): + # remove table if it exists + try: + cur.execute(f'DROP TABLE IF EXISTS {table_name}') + conn.commit() + except Exception as e: + logging.error('Failed to drop table: %s', e) + sys.exit(1) + + # Create table if it doesn't exist + try: + cur.execute(f'CREATE TABLE IF NOT EXISTS {table_name} (timestamp TIMESTAMP, server_name TEXT, version TEXT, protocol INT, players INT, max_players INT, latency INT)') + conn.commit() + except Exception as e: + logging.error('Failed to create table: %s', e) + sys.exit(1) + + return conn + + +def get_minecraft_server_status(host: str) -> Dict[str, str]: + # Get Minecraft server status + try: + server = MinecraftServer.lookup(host) + status = server.status() + logging.info('Successfully got Minecraft server status') + except Exception as e: + logging.error('Failed to get Minecraft server status: %s', e) + sys.exit(1) + + # extract status as dictionary + status = status.__dict__ + return status + + +def main(args, conn=None): + cur = conn.cursor() + try: + # Get current time + timestamp = datetime.datetime.now() + + # Get Minecraft server status + ip_add = vars(args).get('ip', 'localhost:25571') + status = get_minecraft_server_status(ip_add) + table_name = vars(args).get('table', DB_TABLE) + if status: + # Write to postgres the status + cur.execute(f'INSERT INTO {table_name} (timestamp, server_name, version, protocol, players, max_players, latency) VALUES (%s, %s, %s, %s, %s, %s, %s)', (timestamp, status['raw']['description'], status['raw']['version']['name'], status['raw']['version']['protocol'], status['raw']['players']['online'], status['raw']['players']['max'], status['latency'])) + conn.commit() + # Sleep for interval + time.sleep(args.interval) + except KeyboardInterrupt: + logging.info('Exiting...') + sys.exit(0) + except Exception as e: + logging.error('Failed to write to database: %s', e) + # sys.exit(1) + + +if __name__ == '__main__': + # Main loop + args = parse_args() + conn = connect_to_database(args) + while True: + # if connection to server is lost, reconnect + if not conn: + logging.info('Reconnecting to database...') + conn = connect_to_database(args) + + main(args, conn=conn) diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..7b2a0e2 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,73 @@ +worker_processes 1; +daemon off; # Prevent forking + +pid /tmp/nginx.pid; +error_log /var/log/nginx/error.log; + +events { + # defaults +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + access_log /var/log/nginx/access.log combined; + + upstream gunicorn { + server unix:/tmp/gunicorn.sock; + } + + server { + listen 9992 deferred; + client_max_body_size 5m; + + keepalive_timeout 5; + proxy_read_timeout 1200s; + + location ~ ^/ { + # + # Wide-open CORS config for nginx + # + if ($request_method = 'OPTIONS') { + add_header 'Access-Control-Allow-Origin' '*'; + # + # Om nom nom cookies + # + add_header 'Access-Control-Allow-Credentials' 'true'; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; + # + # Custom headers and headers various browsers *should* be OK with but aren't + # + add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type'; + # + # Tell client that this pre-flight info is valid for 20 days + # + add_header 'Access-Control-Max-Age' 1728000; + add_header 'Content-Type' 'text/plain charset=UTF-8'; + add_header 'Content-Length' 0; + return 204; + } + if ($request_method = 'POST') { + add_header 'Access-Control-Allow-Origin' '*'; + add_header 'Access-Control-Allow-Credentials' 'true'; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; + add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type'; + } + if ($request_method = 'GET') { + add_header 'Access-Control-Allow-Origin' '*'; + add_header 'Access-Control-Allow-Credentials' 'true'; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; + add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type'; + } + + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $http_host; + proxy_redirect off; + proxy_pass http://gunicorn; + } + + location / { + return 404 "{}"; + } + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1df98a7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +gunicorn +gevent +flask +mcstatus +psycopg2-binary +pandas +python-dotenv \ No newline at end of file diff --git a/serve.py b/serve.py new file mode 100755 index 0000000..7903161 --- /dev/null +++ b/serve.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python + +# This file implements the API shell. +# You don't necessarily need to modify it for various algorithms. +# It starts nginx and gunicorn with the correct configurations and +# then waits until gunicorn exits. +# +# The flask server is specified to be the app object in wsgi.py +# +# We set the following parameters: +# +# Parameter Environment Variable Default Value +# --------- -------------------- ------------- +# number of workers MODEL_SERVER_WORKERS the number of CPU cores +# timeout MODEL_SERVER_TIMEOUT 60 seconds + +from __future__ import print_function + +import multiprocessing +import os +import signal +import subprocess +import sys + +cpu_count = multiprocessing.cpu_count() + +model_server_timeout = os.environ.get("MODEL_SERVER_TIMEOUT", 60) +model_server_workers = int(os.environ.get("MODEL_SERVER_WORKERS", cpu_count)) + + +def sigterm_handler(nginx_pid, gunicorn_pid): + try: + os.kill(nginx_pid, signal.SIGQUIT) + except OSError: + pass + try: + os.kill(gunicorn_pid, signal.SIGTERM) + except OSError: + pass + + sys.exit(0) + + +def start_server(): + print(f"Starting server with {model_server_workers} workers.") + + # link the log streams to stdout/err so that + # they will be logged to the container logs + subprocess.check_call(["ln", "-sf", "/dev/stdout", "/var/log/nginx/access.log"]) + subprocess.check_call(["ln", "-sf", "/dev/stderr", "/var/log/nginx/error.log"]) + + nginx = subprocess.Popen(["nginx", "-c", "/opt/program/nginx.conf"]) + gunicorn = subprocess.Popen( + [ + "gunicorn", + "--timeout", + str(model_server_timeout), + "-k", + "gevent", + "-b", + "unix:/tmp/gunicorn.sock", + "-w", + str(model_server_workers), + "wsgi:app", + ] + ) + + # NOTE: https://docs.python.org/3/library/signal.html#signal.signal + # This sets a handler (second argument) for the signal SIGTERM (first argument) + # in the function `signal.signal` which we want to call (unclear why...) + + # The handler should be a callable which takes two arguments (hence `a`, `b`), + # and it is called with two arguments: the signal number and current stack frame. + # Neither of these are relevant to us, so we define a dummy handler which kills + # the two processes we have started (nginx and gunicorn) regardless of `a`/`b`. + + # Basically, we are hard-coding "kill these two processes if you get a SIGTERM" + def handler(a, b, c): + return sigterm_handler(nginx.pid, gunicorn.pid) + + signal.signal(signal.SIGTERM, handler) + + # If either subprocess exits, so do we. + pids = set([nginx.pid, gunicorn.pid]) + while True: + pid, _ = os.wait() + if pid in pids: + break + + sigterm_handler(nginx.pid, gunicorn.pid) + print("API server exiting") + + +# The main routine just invokes the start function. +if __name__ == "__main__": + start_server() diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..80a5bb8 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,36 @@ + + + + + + +
+ + ++ {{ status|safe }} +
+