Michael Pilosov
2 years ago
10 changed files with 536 additions and 1 deletions
@ -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 |
@ -1,3 +1,20 @@ |
|||
# mc-status-page |
|||
|
|||
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. |
|||
|
@ -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 <b>{ online }</b> 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) |
@ -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 |
@ -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) |
@ -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 "{}"; |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,7 @@ |
|||
gunicorn |
|||
gevent |
|||
flask |
|||
mcstatus |
|||
psycopg2-binary |
|||
pandas |
|||
python-dotenv |
@ -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() |
@ -0,0 +1,36 @@ |
|||
<!-- Flask template for displaying the results of a SQL query as a basic website --> |
|||
|
|||
<!DOCTYPE html> |
|||
|
|||
<html> |
|||
|
|||
<head> |
|||
|
|||
<meta charset="UTF-8"> |
|||
<title>Minecraft Server Status</title> |
|||
</head> |
|||
|
|||
<body> |
|||
|
|||
<h1>Minecraft Server Status</h1> |
|||
<div align="center"> |
|||
<p style="font-size: 150%;"> |
|||
{{ status|safe }} |
|||
</p> |
|||
</div> |
|||
<br> |
|||
<!-- inline style to center table elements and create spacing in each column--> |
|||
<style> |
|||
table, th, td { |
|||
border: 1px solid black; |
|||
border-collapse: collapse; |
|||
text-align: center; |
|||
padding: 5px; |
|||
font-size: 125%; |
|||
} |
|||
</style> |
|||
<div align="center"> |
|||
{{ data|safe }} |
|||
</div> |
|||
</body> |
|||
</html> |
@ -0,0 +1,7 @@ |
|||
import app as myapp |
|||
|
|||
# This is just a simple wrapper for gunicorn to find your app. |
|||
# If you want to change the algorithm file, simply change "main" |
|||
# above to the new file. |
|||
|
|||
app = myapp.app |
Loading…
Reference in new issue