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 |
# mc-status-page |
||||
|
|
||||
Logs minecraft status to postgres and creates webpage based on current server status using mcstatus. |
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